"""
.. moduleauthor:: Riley Baird <rbaird@odot.org>
.. moduleauthor:: Emma Baker <ebaker@odot.org>

This module handles the validation functionality for the Toolkit.

At the heart of the Toolkit's validation functionality is the :class:`NG911Validator` class, a subclass of :class:`~ng911ok.lib.gdbsession.NG911Session` (which is a subclass of :class:`arcpy.EnvManager`). To perform any validation on the contents of an NG911 geodatabase, an :class:`NG911Validator` instance is initialized with the geodatabase's path as the first argument.

Once initialized, the validator's :term:`validation method`\ s or :term:`validation routine`\ s can be called to test the validity of numerous aspects of the geodatabase and its contents. All validation methods and routines return lists of instances of :class:`~ng911ok.lib.validation.ValidationErrorMessage`.

:class:`~ng911ok.lib.validation.ValidationErrorMessage` has two subclasses, one for each error table that is generated upon the calling of the validator's ``__exit__()`` method:

* :class:`~ng911ok.lib.validation.GDBErrorMessage` - For validation issues with the properties of the geodatabase's contents or the geodatabase itself (e.g., spatial reference, field type)
* :class:`~ng911ok.lib.validation.FeatureAttributeErrorMessage` - For validation issues with the attributes of the feature classes (e.g., NGUID format violations, parity inconsistencies)

.. seealso::

    :ref:`Validation Glossary <validation-glossary>`

=======
Members
=======
"""
import logging
import os
from collections.abc import Callable, Sequence, Iterable, KeysView
from functools import partial, cache, wraps
from itertools import product, combinations, chain
from os import PathLike
from os.path import dirname
from pathlib import Path
from time import perf_counter, time_ns
from typing import Any, cast, get_args, ClassVar, Self, Optional, Literal, TypeAlias

import arcpy.management
import attrs
import numpy as np
import pandas as pd
import yaml
import arcgis.geometry as ag
# noinspection PyUnresolvedReferences
from arcgis.features import GeoSeriesAccessor, GeoAccessor
# noinspection PyProtectedMember
from pandas._libs.missing import NAType  # Used for type hinting
from typing_extensions import ParamSpec
from datetime import datetime  # Must be imported after ArcPy

from .topology import topology_config, TopologyRule
from .geomutils import get_vertex, get_segment, AnyPolyline, point_beside_line
from .session import GDB_ERROR_TABLE_NAME, FEATURE_ATTRIBUTE_ERROR_TABLE_NAME, config
from .gdbsession import GPMessenger, NG911Session
from .accessor import AddressRange, NGUIDFormatError, NGUID, Directionality
from .config_dataclasses import NG911FeatureClass, NG911Field, NG911Domain
from .iterablenamespace import FrozenList, FrozenDict
from .legacy import LegacyFieldInfo
from .misc import GPParameterValue, Parity, calculate_parity, unwrap, DOMAIN_TYPES, ValidationCategory, GDBErrorCode, FeatureAttributeErrorCode, Severity, quote
from .validation import ValidationErrorMessage, GDBErrorMessage, FeatureAttributeErrorMessage, ValidationErrorMessage_co

_logger = logging.getLogger(__name__)

ValidationParamSpec = ParamSpec("ValidationParamSpec")
ValidationFunction: TypeAlias = Callable[ValidationParamSpec, list[ValidationErrorMessage]]
ValidationRoutineFunction: TypeAlias = Callable[["NG911Validator"], list[ValidationErrorMessage]]
FeatureClassValidationRoutineFunction: TypeAlias = Callable[["NG911Validator", str], list[ValidationErrorMessage]]
AnyValidationRoutineFunction: TypeAlias = ValidationRoutineFunction | FeatureClassValidationRoutineFunction


class ValidationRoutineLookupError(Exception):
    """Exception to be raised when :meth:`ValidationRoutine.get_routine` is
    used to retrieve a routine that does not exist."""
    pass


@attrs.frozen
class ValidationRoutine:
    """
    Callable representation of a :term:`validation routine`. Instantiation of this
    class registers the instance in the :attr:`_registered_routines` class attribute.
    The instances registered in :attr:`_registered_routines` are used to populate
    the validation routine selection parameters in the
    :mod:`Validate Geodatabase <ng911ok.tools.validation.validate>` tool.

    In addition to using ``__init__()``, instances of this class can be created
    with the :func:`routine` decorator, which uses the decorated function as
    the new instance's :attr:`routine_function`.

    When an instance of this class is accessed as a descriptor from an instance
    of :class:`NG911Validator`, an instance of :class:`_ValidationRoutineProxy`
    is returned. The proxy object has access to all attributes of the actual
    :class:`ValidationRoutine` instance and is callable; calling the proxy instance
    ensures that the :class:`NG911Validator` instance is passed as an argument to
    :attr:`routine_function`.

    This class also contains class methods to help generate and manage
    ``arcpy.Parameter`` objects involving validation routines.
    """

    _registered_routines: ClassVar[dict[str, Self]] = {}
    """Registry of instances of this class."""

    name: str
    """Name of the routine. This is the text that appears in the routine
    selection box in the geoprocessing tool."""

    category: ValidationCategory
    """The category under which the routine should be listed in the
    geoprocessing tool."""

    routine_function: AnyValidationRoutineFunction
    """Function that performs the logic for this routine."""

    takes_feature_class_argument: bool
    """Whether an instance of :class:`NG911FeatureClass` is expected as an
    argument to :attr:`routine_function`."""

    required_feature_classes: frozenset[NG911FeatureClass] = attrs.field(converter=frozenset, factory=frozenset)
    """The feature classes (as :class:`NG911FeatureClass` instances) that are
    required to be present in order to successfully execute the routine."""

    def __attrs_post_init__(self) -> None:
        """
        After instantiation, the new instance is registered in the class
        attribute :attr:`_registered_routines`.
        """
        self._register(self)

    def __call__(self, validator: "NG911Validator", /, *args, reraise: bool = False, **kwargs) -> list[ValidationErrorMessage]:
        if not isinstance(validator, NG911Validator):
            _logger.critical(f"Attempted to call a ValidationRoutine instance without appropriate 'validator' argument. | Type: '{type(validator)}' | Value: '{validator}'")
            raise TypeError("Argument 'validator' must be an instance of NG911Validator.")
        _logger.debug("\n\t".join(line for line in (f"Starting routine '{self.name}'.", f"args: {args}" if args else None, f"kwargs: {kwargs}" if kwargs else None) if line))
        errors: list[ValidationErrorMessage] = []
        start: float = perf_counter()

        try:
            errors = self.routine_function(validator, *args, **kwargs)

        except ValidationPrerequisiteError as exc:
            _logger.warning(f"Routine '{self.name}' failed (ValidationPrerequisiteError). Generated {len(errors)} validation error(s).")
            exc.arcpy_warning(self, validator.messenger, *args, **kwargs)
            if reraise:
                raise exc

        except NotImplementedError as exc:
            msg: str = f"Routine '{self.name}' has not been implemented."
            _logger.error(msg)
            validator.messenger.addWarningMessage(msg)
            if reraise:
                raise exc

        except (RuntimeError, KeyError, AttributeError, IndexError) as exc:
            _logger.critical(f"Routine '{self.name}' encountered an error:", exc_info=exc)
            validator.messenger.addWarningMessage(f"Routine '{self.name}' encountered an error:\n\t{exc.__class__.__name__}: {exc}")
            errors.append(GDBErrorMessage("Error", "ERROR:PYTHON:EXCEPTION", message=f"{exc.__class__.__name__}: {exc}"))
            if reraise:
                raise exc

        else:
            _logger.debug(f"Successfully completed routine '{self.name}'.")

        end: float = perf_counter()
        _logger.info(f"Ran routine '{self.name}' in {(end - start):.2f} seconds. Generated {len(errors)} validation error(s).")
        return errors

    def __get__(self, instance: "NG911Validator", owner: type["NG911Validator"]) -> "_ValidationRoutineProxy | Self":
        if isinstance(instance, NG911Validator):
            # When an instance of this class is accessed as an attribute of NG911Validator, returning a proxy object provides for the calling of the instance (note that __call__() simply calls self.routine_function()) with the validator instance as the first argument to routine_function().
            return _ValidationRoutineProxy(self, instance)
        elif instance:
            raise TypeError("ValidationRoutine instance bound to inappropriate object.")
        else:
            return self

    def __str__(self):
        return f"<{self.category} Validation Routine '{self.name}'>"

    @classmethod
    def _register(cls, instance: Self) -> None:
        if instance.name in cls._registered_routines:
        # if instance.name.upper() in [r.name.upper() for r in cls._registered_routines]:
            raise KeyError(f"There is already a routine named '{instance.name}'. Two routines may not share a name, even if they are in separate categories.")
        else:
            cls._registered_routines[instance.name] = instance
        cls._parameter_names_for_all_categories.cache_clear()
        # cls._registered_routines.append(instance)
        # _logger.debug(f"Registered ValidationRoutine instance {instance}.")

    @classmethod
    def get_routine(cls, name: str) -> Self:
        """Returns the routine named *name*."""
        # matches = [r for r in cls._registered_routines if r.name == name]
        # if len(matches) == 1:
        #     return matches[0]
        # elif len(matches) == 0:
        try:
            return cls._registered_routines[name]
        except KeyError:
            raise ValidationRoutineLookupError(f"No routine named '{name}'.")
        # else:
        #     raise ValidationRoutineLookupError(f"Somehow, there are multiple routines named '{name}'. That is a problem. Please report this bug to the developers.")

    @classmethod
    def get_routines(cls) -> FrozenList[Self]:
        """
        Returns all registered instances of :class:`ValidationRoutine`.

        .. versionchanged:: 3.1.0
            Now returns routine instances in accordance with the hinted return
            type instead of routine names.
        """
        return FrozenList(cls._registered_routines.values())

    @classmethod
    def get_routine_names(cls) -> FrozenList[str]:
        """Returns the names of all registered instances of
        :class:`ValidationRoutine`."""
        return FrozenList(cls._registered_routines.keys())

    @classmethod
    def get_category(cls, category: ValidationCategory) -> FrozenList[Self]:
        """Returns all registered instances of ``ValidationRoutine`` with a
        ``category`` attribute set to the argument provided for *category*."""
        return FrozenList(r for r in cls._registered_routines.values() if r.category == category)

    @classmethod
    def parameter_name_for_category(cls, category: ValidationCategory) -> str:
        """Generates a name for a geoprocessing parameter for *category*."""
        param_base_name: str = category.replace(" ", "_").lower()
        return f"{param_base_name}_validations"  # e.g., "General Feature Class" -> "general_feature_class_validations"

    @classmethod
    def get_category_from_parameter(cls, parameter: arcpy.Parameter) -> ValidationCategory:
        """Given a parameter, returns the :class:`ValidationCategory` it
        represents."""
        names: dict[str, ValidationCategory] = {cls.parameter_name_for_category(category): category for category in get_args(ValidationCategory)}
        try:
            return names[parameter.name]
        except KeyError as exc:
            raise ValueError(f"Parameter with name {parameter.name} is not a routine parameter.") from exc

    @classmethod
    @cache
    def _parameter_names_for_all_categories(cls) -> set[str]:
        return {cls.parameter_name_for_category(c) for c in get_args(ValidationCategory)}

    @classmethod
    def populate_parameter_value_table(cls, routine_parameter: arcpy.Parameter, select_all: bool = False) -> None:
        """
        Given a parameter, populates its value table. The value table will
        have one row for each routine in the parameter's category. Each row
        will contain:

        * [0] - The name of the routine
        * [1] - The argument provided for *select_all*
        """
        category: ValidationCategory = cls.get_category_from_parameter(routine_parameter)
        routine_parameter.values = [[r.name, select_all] for r in cls.get_category(category)]

    @classmethod
    def parameter_for_category(cls, category: ValidationCategory) -> arcpy.Parameter:
        """Creates an instance of ``arcpy.Parameter`` containing options for
        each routine in a given validation category."""
        # routine_parameter = arcpy.Parameter(
        #     displayName=f"{category} Validations",  # e.g., "General Feature Class" -> "General Feature Class Validations"
        #     name=cls.parameter_name_for_category(category),
        #     datatype="GPString",
        #     parameterType="Optional",
        #     direction="Input",
        #     enabled=False,
        #     multiValue=True
        # )
        # routine_parameter.controlCLSID = "{38C34610-C7F7-11D5-A693-0008C711C8C1}"
        # routine_parameter.filter.list = [r.name for r in cls.get_category(category)]
        routine_parameter = arcpy.Parameter(
            displayName=f"{category} Validations",  # e.g., "General Feature Class" -> "General Feature Class Validations"
            name=cls.parameter_name_for_category(category),
            datatype="GPValueTable",
            parameterType="Optional",
            direction="Input",
            enabled=False,
            multiValue=True
        )
        routine_parameter.columns = [['GPString', 'Routine', 'ReadOnly'], ['GPBoolean', 'Run?']]
        return routine_parameter

    @classmethod
    def parameters_for_all_categories(cls) -> list[arcpy.Parameter]:
        """Generates geoprocessing parameters for all values of ``ValidationCategory``."""
        return [cls.parameter_for_category(category) for category in get_args(ValidationCategory)]

    @classmethod
    def is_routine_parameter_name(cls, parameter_name: str) -> bool:
        """Given the name of an instance of ``arcpy.Parameter``, returns
        whether it could be the name of a routine parameter (i.e., whether the
        name could be returned by :meth:`parameter_name_for_category`)."""
        return parameter_name in cls._parameter_names_for_all_categories()

    @classmethod
    def get_selected_from_parameter(cls, routine_parameter: arcpy.Parameter) -> list[Self]:
        """Returns a list containing the :class:`ValidationRoutine` objects
        corresponding to the options that are selected."""
        return [cls.get_routine(routine_name) for routine_name, selected in (unwrap(routine_parameter) or []) if selected]


class _ValidationRoutineProxy:
    """
    Proxy object returned when an instance of :class:`ValidationRoutine` is
    accessed as a descriptor. Each instance has access to the attributes of the
    underlying ``ValidationRoutine`` instance. When called, the proxy calls the
    underlying ``ValidationRoutine`` instance with the :class:`NG911Validator`
    instance to which it is bound as an argument.

    .. NOTE::

       This class should only ever be instantiated by
       :meth:`ValidationRoutine.__get__`.

    .. versionchanged:: 3.1.0

        This class was renamed from ``ValidationRoutineProxy``.
    """

    def __init__(self, real_routine: "ValidationRoutine", validator: "NG911Validator"):
        self._routine = real_routine
        """Reference to the actual routine instance."""

        self._validator = validator
        """Reference to the validator instance calling the routine."""

    def __getattr__(self, item):
        return getattr(self._routine, item)

    def __call__(self, *args, **kwargs):
        return self._routine(self._validator, *args, **kwargs)


class ValidationPrerequisiteError(RuntimeError):
    """Type of error to be raised when ``precheck()`` generates a validation
    error as a result of a *prerequisite* validation function."""

    @staticmethod
    def _arg_str(*routine_args, **routine_kwargs) -> str:
        arg_strs = quote(routine_args)
        kwarg_strs = [f"{k}='{v}'" for k, v in routine_kwargs.items()]
        return ", ".join(arg_strs + kwarg_strs)

    def _msg_str(self, validation_routine: "ValidationRoutine", messenger: Optional[GPMessenger] = None, *routine_args, **routine_kwargs):
        if routine_args or routine_kwargs:
            arg_str = self._arg_str(*routine_args, **routine_kwargs)
            message: str = f"Validation Prerequisite Error: Routine '{validation_routine.name}' [with {arg_str}] failed. {self.args[0]}"
        else:
            message: str = f"Validation Prerequisite Error: Routine '{validation_routine.name}' failed. {self.args[0]}"
        return message

    @property
    def warning_message(self) -> str:
        """Returns a warning message suitable for logging."""
        return f"Validation Prerequisite Error: A validation could not be completed. {self.args[0]}"

    def arcpy_warning(self, validation_routine: "ValidationRoutine", messenger: Optional[GPMessenger] = None, *routine_args, **routine_kwargs) -> None:
        """Adds an ArcPy warning with an appropriate message."""
        message: str = self._msg_str(validation_routine, messenger, *routine_args, *routine_kwargs)
        _logger.warning(message)
        if messenger:
            messenger.addWarningMessage(message)
        else:
            arcpy.AddWarning(message)


def routine(
        name: str,
        category: ValidationCategory,
        takes_feature_class_argument: bool = False,
        required_feature_classes: Optional[Iterable[NG911FeatureClass | str]] = None
) -> Callable[[AnyValidationRoutineFunction], ValidationRoutine]:
    """
    Decorator factory for generating decorators to be used on instance methods
    of :class:`NG911Validator` that represent validation routines. This factory
    function returns a ``functools.partial`` object that, when used as a
    decorator, results in an instance of :class:`ValidationRoutine`.

    .. seealso::

       The documentation for the attributes of :class:`ValidationRoutine`.

    :param name: Name of the routine
    :type name: str
    :param category: Category of the routine
    :type category: ValidationCategory
    :param takes_feature_class_argument: Whether the routine takes a feature
        class as an argument
    :type takes_feature_class_argument: bool
    :param required_feature_classes: Feature classes (or
        :attr:`~.config_dataclasses.NG911FeatureClass.role` attribute values
        thereof) required for the routine to run
    :type required_feature_classes: Iterable[Union[NG911FeatureClass, str]]
    :return: Decorator for a validation routine function
    :rtype: functools.partial
    """
    required_feature_classes = required_feature_classes or frozenset()
    required_feature_classes = frozenset(config.feature_classes[fc] if isinstance(fc, str) else fc for fc in required_feature_classes)
    return partial(ValidationRoutine, name, category, takes_feature_class_argument=takes_feature_class_argument, required_feature_classes=required_feature_classes)


# TODO: Consider approach to schema validation using arcpy.da.Describe like so:
# gdb_describe = arcpy.da.Describe(self.str_gdb_path)
# rds_describe = [item for item in gdb_describe["children"] if item.get("name") == "NG911"][0]
# field: arcpy.Field
# for fc_name in rds_describe["children"]:
#     if fc_name not in config.required_feature_class_names:
#         ...  # Feature class not allowed
#         continue
#     fc_obj = config.get_feature_class_by_name(fc_name)
#     fc_describe = arcpy.da.Describe(self.str_path_to(fc_obj))
#     for field in fc_describe["fields"]:
#         if field.required:
#             continue  # Skip
#         try:
#             field_obj = config.get_field_by_name(field.name, False)
#         except:
#             ...  # Field not allowed
#             continue
#         if field_obj.role not in fc_obj.fields:
#             ...  # Field not in this feature class
#         if field.name != field_obj.name:
#             ...  # Presumably a letter-case issue
#         if field.type != field_obj.type:
#             ...  # Wrong field type
#         if field.domain != (field_obj.domain.name if field_obj.domain else None):
#             ...  # Wrong domain
#         if field.length != field_obj.length:
#             ...  # Wrong length


class NG911Validator(NG911Session):
    """
    This class exists to encapsulate the logic of validation against the
    Standards.

    ===========
    Conventions
    ===========

    Most validation functions follow the conventions outlined here.

    ------
    Naming
    ------

    Validation functions should be instance methods whose names start with
    ``check_``. Validation helper functions should be static or instance
    methods whose names should start with ``_check_``.

    -------
    Caching
    -------

    Certain validation functions that are called often, such as when their
    results are passed to :meth:`_precheck`, should be decorated with
    ``@cache``. All methods decorated as such shall have a call to
    ``cache_clear()`` in this class's :meth:`_clear_method_caches`.

    ------------------------
    Prerequisite Validations
    ------------------------

    The :meth:`_precheck` method is intended to be called by validation functions
    and routines to mandate that certain aspects of the data must be valid
    prior to running a validation. For example, :meth:`check_unique_id_format`
    requires that the input feature class has a unique ID field, and enforces
    this prerequisite by passing the results of a call to
    :meth:`check_fields_exist` to ``_precheck()`` to ensure the presence of that
    field. Should the call return any errors, ``_precheck()`` raises a
    :exc:`ValidationPrerequisiteError`.

    ------------------------------
    Validation Function Parameters
    ------------------------------

    Validation functions may take an :class:`NG911FeatureClass` object and/or a
    sequence of :class:`NG911Field` objects. On occasion, other arguments may
    be appropriate, but never should a validation function take the string role
    or name of a feature class or field as an argument.

    Validation functions should never take a geodatabase as an argument.
    Since :class:`NG911Validator`` is a subclass of ``arcpy.EnvManager``,
    its methods can get the path to the current workspace from
    ``arcpy.env.workspace``, or, preferably, from the property :attr:`gdb`.

    Validation helper functions should be static methods, or, if necessary,
    class methods. They should therefore never modify an instance's
    :attr:`_validation_issues`. They may take any arguments and return any type
    of data.

    ---------------------------------
    Validation Function Return Values
    ---------------------------------

    Validation functions should have a return type of
    ``list[ValidationErrorMessage]``.
    """

    _validation_issues: list[ValidationErrorMessage]
    _enter_timestamp: datetime | None  # Keeps track of when __enter__() was last called; used for error message timestamps
    export: bool
    overwrite: bool

    def __init__(self, workspace: os.PathLike[str] | str, respect_submit: bool, use_edit_session: bool = False, messenger: Optional[GPMessenger] = None, export: bool = True, overwrite_error_tables: bool = False, **env_kwargs):
        """
        Initializes an ``NG911Validator`` instance. This class is a context
        manager and a subclass of ``arcpy.EnvManager``, and it is intended to
        be used in a ``with`` statement where *workspace* is the path to the
        geodatabase to be validated.

        :param workspace: Path to the geodatabase subject to validation
        :type workspace: PathLike[str] | str
        :param respect_submit: Whether to only analyze features where the
            ``SUBMIT`` attribute is ``"Y"``
        :type respect_submit: bool
        :param use_edit_session: Whether to automatically open and close an
            ArcPy edit session, default ``False``
        :type use_edit_session: bool
        :param messenger: ArcPy message-handling object; this is the
            ``messages`` argument passed to the ``execute`` method of a
            script-based geoprocessing tool. If ``None`` (the default), this is
            set to a fresh instance of :class:`FallbackGPMessenger`.
        :type messenger: GPMessenger
        :param export: Whether to export validation errors generated and stored
            by this instance to tables inside *workspace*, default ``True``
        :type export: bool
        :param overwrite_error_tables: Whether to overwrite validation error tables if they
            already exist, default ``False`` (no effect if ``export=False``)
        :type overwrite_error_tables: bool
        :param env_kwargs: Additional arguments to be passed to the
            ``arcpy.EnvManager`` initializer
        :type env_kwargs: Any
        """
        super().__init__(workspace=workspace, respect_submit=respect_submit, messenger=messenger, use_edit_session=use_edit_session, **env_kwargs)
        self._validation_issues = []  # TODO: Change to set
        self._enter_timestamp = None
        self.export = export
        self.overwrite = overwrite_error_tables
        _logger.debug(f"Validator initialized with messenger of type: '{type(self.messenger).__qualname__}'")

    def __enter__(self) -> Self:
        self._enter_timestamp = datetime.now()  # Ensures all validation error messages will have the same timestamps in the output tables
        _logger.info(f"Entering {__class__.__name__} context manager for workspace: {self._gdb}")
        super().__enter__()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        if self.export:
            self.export_gdb_error_table()
            self.export_fa_error_table()

        self._clear_method_caches()
        self._enter_timestamp = None

        _logger.info(f"Exiting {__class__.__name__} context manager "
                     f"for workspace '{self.gdb}' with {self.issue_count} "
                     f"message(s) recorded ({len(self.gdb_issues)} GDB Error(s); "
                     f"{len(self.feature_attribute_issues)} Feature Attribute Error(s)).")
        return super().__exit__(exc_type, exc_val, exc_tb)

    def __str__(self) -> str:
        return f"<{self.__class__.__name__} in {self.gdb_path.absolute()}>"

    def export_gdb_error_table(self) -> tuple[bool, bool]:
        """
        Exports GDB issues, if applicable. If ``self.overwrite`` is ``True``,
        this will delete the current GDB issue table if it exists, regardless
        of whether there are new issues to export. If ``self.overwrite`` is
        ``False`` and a GDB issue table already exists, new issues will be
        appended to the existing table.

        :return: Whether an existing table was deleted and whether any new
            output was written, respectively
        :rtype: tuple[bool, bool]
        """
        did_delete, did_export = False, False
        tcr_df = self.gdb_issue_df  # [T]emplate[C]heck[R]esults [D]ata[F]rame
        if tcr_df.empty:
            if self.overwrite and arcpy.Exists(GDB_ERROR_TABLE_NAME):
                arcpy.management.Delete(self.gdb_error_table)
                did_delete = True
                _logger.info("Deleted old GDB Issue Table.")
            _logger.info(f"No GDB issues to export.")
        else:
            if not self.overwrite and arcpy.Exists(GDB_ERROR_TABLE_NAME):
                _logger.info(f"Concatenating, not overwriting, GDB issues.")
                out_suffix = int(datetime.now().timestamp())
                tcr_df.spatial.to_table(f"memory/tcr_{out_suffix}", sanitize_columns=False)
                arcpy.management.Append(f"memory/tcr_{out_suffix}", self.gdb_error_table)
            else:
                tcr_df.spatial.to_table(self.gdb_error_table, sanitize_columns=False)
            did_export = True
            _logger.info(f"Wrote GDB Error Table to: {self.gdb_error_table}")
        return did_delete, did_export

    def export_fa_error_table(self) -> tuple[bool, bool]:
        """
        Exports feature attribute (FA) issues, if applicable. If
        ``self.overwrite`` is ``True``, this will delete the current FA issue
        table if it exists, regardless of whether there are new issues to
        export. If ``self.overwrite`` is ``False`` and a GDB issue table
        already exists, new issues will be appended to the existing table.

        :return: Whether an existing table was deleted and whether any new
            output was written, respectively
        :rtype: tuple[bool, bool]
        """
        did_delete, did_export = False, False
        fvcr_df = self.feature_attribute_issue_df  # [F]ield[V]alues[C]heck[R]esults [D]ata[F]rame
        if fvcr_df.empty:
            if self.overwrite and arcpy.Exists(FEATURE_ATTRIBUTE_ERROR_TABLE_NAME):
                arcpy.management.Delete(self.fa_error_table)
                did_delete = True
                _logger.info("Deleted old FA Issue Table.")
            _logger.info(f"No FA issues to export.")
        else:
            if not self.overwrite and arcpy.Exists(FEATURE_ATTRIBUTE_ERROR_TABLE_NAME):
                _logger.info(f"Concatenating, not overwriting, FA issues.")
                out_suffix = int(datetime.now().timestamp())
                fvcr_df.spatial.to_table(f"memory/fvcr_{out_suffix}", sanitize_columns=False)
                arcpy.management.Append(f"memory/fvcr_{out_suffix}", self.fa_error_table)
            else:
                fvcr_df.spatial.to_table(self.fa_error_table, sanitize_columns=False)
            did_export = True
            _logger.info(f"Wrote FA Error Table to: {self.fa_error_table}")
        return did_delete, did_export

    @property
    def validation_issues(self) -> FrozenList[ValidationErrorMessage]:
        """Returns all validation issues logged by the instance as a
        :class:`FrozenList`."""
        return FrozenList(self._validation_issues)

    @property
    def validation_errors(self) -> FrozenList[ValidationErrorMessage]:
        """
        Returns all validation errors logged by the instance as a
        :class:`FrozenList`.

        .. versionchanged:: 3.1.0
            Now only returns obejcts with a :term:`severity` of ``Error``.
        """
        return FrozenList(issue for issue in self._validation_issues if issue.severity == "Error")

    @property
    def gdb_issues(self) -> FrozenList[GDBErrorMessage]:
        """Returns all ``GDBErrorMessage``\ s logged by the instance as a
        :class:`FrozenList`."""
        return FrozenList(issue for issue in self._validation_issues if isinstance(issue, GDBErrorMessage))

    @property
    def gdb_errors(self) -> FrozenList[GDBErrorMessage]:
        """
        Of all :class:`GDBErrorMessage`\ s logged by the instance, returns
        those with a :attr:`~GDBErrorMessage.severity` of ``Error`` as a
        :class:`FrozenList`.

        .. versionchanged:: 3.1.0
            Now only returns obejcts with a :term:`severity` of ``Error``.
        """
        return FrozenList(
            issue for issue
            in self._validation_issues
            if isinstance(issue, GDBErrorMessage)
            and issue.severity == "Error"
        )

    @property
    def gdb_issue_df(self) -> pd.DataFrame:
        """Returns data from the :class:`GDBErrorMessage`\ s logged by the
        instance as a data frame."""
        df = pd.DataFrame(
            [msg.to_series(self._enter_timestamp) for msg in self.gdb_issues]
        )
        if df.empty:
            return df
        else:
            return df.astype({
                "Timestamp": "datetime64[ns]",
                "Severity": pd.StringDtype(),
                "Code": pd.StringDtype(),
                "Layer": pd.StringDtype(),
                "Field": pd.StringDtype(),
                "Message": pd.StringDtype()
            }).drop_duplicates(keep="first").sort_values([
                "Timestamp", "Severity", "Code", "Layer", "Field", "Message"
            ])

    @property
    def feature_attribute_issues(self) -> FrozenList[FeatureAttributeErrorMessage]:
        """Returns all :class:`FeatureAttributeErrorMessage`\ s logged by the
        instance as a :class:`FrozenList`."""
        return FrozenList(issue for issue in self._validation_issues if isinstance(issue, FeatureAttributeErrorMessage))

    @property
    def feature_attribute_errors(self) -> FrozenList[FeatureAttributeErrorMessage]:
        """
        Of all :class:`FeatureAttributeErrorMessage`\ s logged by the instance,
        returns those with a :attr:`~FeatureAttributeErrorMessage.severity` of
        ``Error`` as a :class:`FrozenList`.

        .. versionchanged:: 3.1.0
            Now only returns obejcts with a :term:`severity` of ``Error``.
        """
        return FrozenList(
            issue for issue
            in self._validation_issues
            if isinstance(issue, GDBErrorMessage)
            and issue.severity == "Error"
        )

    @property
    def feature_attribute_issue_df(self) -> pd.DataFrame:
        """Returns data from the
        :class:`~.validation.FeatureAttributeErrorMessage`\ s logged by the
        instance as a data frame."""
        df = pd.DataFrame(
            [msg.to_series(self._enter_timestamp) for msg in self.feature_attribute_issues]
        )
        if df.empty:
            return df
        else:
            return df.astype({
                "Timestamp": "datetime64[ns]",
                "Severity": pd.StringDtype(),
                "Code": pd.StringDtype(),
                "Layer1": pd.StringDtype(),
                "NGUID1": pd.StringDtype(),
                "Field1": pd.StringDtype(),
                "Value1": pd.StringDtype(),
                "Layer2": pd.StringDtype(),
                "NGUID2": pd.StringDtype(),
                "Field2": pd.StringDtype(),
                "Value2": pd.StringDtype(),
                "Message": pd.StringDtype()
            }).drop_duplicates(keep="first").sort_values([
                "Timestamp", "Severity", "Code", "Layer1", "Field1", "Layer2", "Field2", "NGUID1", "NGUID2", "Value1", "Value2", "Message"]
            )

    @property
    def issue_count(self) -> int:
        """Returns the number of :term:`validation issue`\ s logged by the
        instance, regardless of :term:`severity`."""
        return len(self._validation_issues)

    @property
    def error_count(self) -> int:
        """
        Returns the number of :term:`validation error`\ s logged by the
        instance.

        .. versionchanged:: 3.1.0
            Now returns only the number of :term:`validation issue`\ s with a
            :term:`severity` of ``Error``.
        """
        return len([issue for issue in self._validation_issues if issue.severity == "Error"])

    @property
    def has_issues(self) -> bool:
        """Returns ``True`` if any :term:`validation issue`\ s have been logged
        by the instance, or ``False`` otherwise."""
        return self.issue_count > 0

    @property
    def has_errors(self) -> bool:
        """
        Returns ``True`` if any :term:`validation error`\ s have been logged by
        the instance, or ``False`` otherwise.

        .. versionchanged:: 3.1.0
            Now returns ``True`` only if any :term:`validation issue`\ s have a
            :term:`severity` of ``Error``.
        """
        return self.error_count > 0

    @property
    def issue_summary(self) -> dict[GDBErrorCode | FeatureAttributeErrorCode, int]:
        """Returns a ``dict`` mapping issue codes to the number of occurrences
        of each code stored by the validator instance."""
        counts = {}
        for issue in self._validation_issues:
            if issue.code not in counts:
                counts[issue.code] = 0
            counts[issue.code] += 1
        return counts

    @property
    def gdb_error_table_path(self) -> Path:
        """Returns the path to the GDB Error Table."""
        return self.gdb_path / GDB_ERROR_TABLE_NAME

    @property
    def gdb_error_table(self) -> str:
        """Returns the path to the GDB Error Table as a string."""
        return self.gdb_error_table_path.__fspath__()

    @property
    def fa_error_table_path(self) -> Path:
        """Returns the path to the Feature Attribute Error Table."""
        return self.gdb_path / FEATURE_ATTRIBUTE_ERROR_TABLE_NAME

    @property
    def fa_error_table(self) -> str:
        """Returns the path to the Feature Attribute Error Table as a
        string."""
        return self.fa_error_table_path.__fspath__()

    # @gdb.setter
    # def gdb(self, path: str):
    #     if self._is_active:
    #         arcpy.env.workspace = path
    #     else:
    #         raise RuntimeError("Validator is not active; either __enter__() has not been called, or __exit__() has already been called.")

    def _add_issues(self, issues: list[ValidationErrorMessage_co]) -> list[ValidationErrorMessage_co]:
        """
        Adds ``issues`` to ``self._validation_issues`` and returns the added
        issues (and *only* the added issues). This is to facilitate the
        following pattern at the end of validation functions::

            return self._add_issues(issues)

        Instead of::

            self._validation_issues += issues
            return issues
        """
        if not all(isinstance(error, GDBErrorMessage | FeatureAttributeErrorMessage) for error in issues):
            raise TypeError
        self._validation_issues += issues
        return issues

    def _clear_method_caches(self) -> None:
        """Clears the caches of methods that use the ``@cache`` decorator."""
        self.check_feature_class_exists.cache_clear()
        self.check_feature_dataset_exists.cache_clear()
        self.check_fields_exist.cache_clear()
        self.check_unique_id_frequency.cache_clear()
        self.check_unique_id_format.cache_clear()
        self.check_feature_class_configuration.cache_clear()

    @staticmethod
    def _precheck(errors: list[ValidationErrorMessage], fail_on_error: bool = True) -> bool:
        """
        This method allows a validation function to require that another
        validation be successfully passed as a prerequisite. It is intended to
        be called with a call to the prerequisite validation function as
        the argument to the *errors* parameter, such as::

            self._precheck(self.check_gdb_config())

        If *fail_on_error* is ``True`` (the default), a
        :class:`ValidationPrerequisiteError` will be raised in the event that
        *errors* contains any error messages with
        :attr:`~ValidationErrorMessage.severity` of ``Error``. If
        *fail_on_error* is ``False``, then ``False`` will be returned in such a
        case.

        Otherwise, ``True`` will be returned,
        indicating that the prerequisite is satisfied.

        :param errors: A list of validation error messages (in practice, a
            call to a validation function; see above)
        :type errors: list[ValidationErrorMessage]
        :param fail_on_error: Whether a ``ValidationPrerequisiteError`` should
            be raised if ``errors`` is truthy
        :type fail_on_error: bool
        :return: Whether the prerequisite was satisfied
        :rtype: bool
        """
        if error_count := len([x for x in errors if x.severity == "Error"]):
            if fail_on_error:
                raise ValidationPrerequisiteError(f"There are {error_count} validation error(s) that must be rectified before proceeding.")
            else:
                return False
        else:
            return True

    def run_all_routines(self) -> bool:
        """
        Runs all registered routines. Routines that take a feature class
        argument are run once for each and every required and optional feature
        class. If this method returns ``True``, the geodatabase has passed
        validation.

        :return: Whether all validations were passed
        :rtype: bool
        """
        errors: list[ValidationErrorMessage] = []

        for r in ValidationRoutine.get_routines():
            if r.takes_feature_class_argument:
                for feature_class_name in config.required_feature_class_names + config.optional_feature_class_names:
                    errors += r(self, feature_class_name)
            else:
                errors += r(self)

        return len(errors) == 0

    def load_df(self, feature_class: NG911FeatureClass, fields: Optional[Sequence[NG911Field | str]] = None, respect_submit: Optional[bool] = None, error_if_empty: bool = True, **kwargs: Any) -> pd.DataFrame:
        """
        Equivalent to :meth:`NG911Session.load_df`, but with an additional
        parameter ``error_if_empty``.

        :param feature_class: The feature class to load
        :type feature_class: NG911FeatureClass
        :param fields: The fields to include, as either ``NG911Field`` objects
            or field names
        :type fields: Optional[list[Union[NG911Field, str]]]
        :param respect_submit: Whether to only return features where the
            ``SUBMIT`` attribute is ``"Y"``, defaults to
            ``self._respect_submit``
        :type respect_submit: Optional[bool]
        :param error_if_empty: Whether to raise a
            ``ValidationPrerequisiteError`` if the loaded ``DataFrame`` has no
            rows, default ``True``
        :type error_if_empty: bool
        :param kwargs: Additional keyword arguments to pass to
            ``pandas.DataFrame.spatial.from_featureclass()``
        :type kwargs: Any
        :return: The feature class data in a ``pandas.DataFrame``
        :rtype: pandas.DataFrame
        """
        if fields:
            config_field_names = config.field_names
            check_fields = FrozenList(
                config.get_field_by_name(field)
                if isinstance(field, str)
                else field
                for field in fields
                if not (
                    isinstance(field, str)
                    and field not in config.field_names
                )
            )
        else:
            check_fields = FrozenList(feature_class.fields.values())
        self._precheck(self.check_fields_exist(feature_class, check_fields))
        result = super().load_df(feature_class, fields, respect_submit, **kwargs)
        if error_if_empty:
            self._precheck(self._check_for_empty_df(result))
        return result

    def _check_for_empty_df(self, df: pd.DataFrame) -> list[GDBErrorMessage]:
        if df.empty:
            return self._add_issues([GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:EMPTY_SUBMISSION", df.attrs["feature_class"].name,
                                                     message=f"Feature class has no features marked for submission, i.e., no features have their '{config.fields.submit:n}' attribute set to 'Y'.")])
            # if self._respect_submit:
            #     message = f"Feature class has no features marked for submission. Set appropriate features' {config.fields.submit.name} to 'Y' or try running with 'Respect Submit' disabled."
            #     GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:EMPTY_SUBMISSION", feature_class.name,
            #                     message=f"Feature class has no features marked for submission, i.e., no features have their '{config.fields.submit.name}' attribute set to 'Y'.")
            # else:
            #     message = "Feature class has no features."
            # return self._add_issues([
            #     GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:EMPTY", df.attrs["feature_class"].name, message=message)
            # ])
        else:
            return []

    @cache
    def check_feature_dataset_exists(self, feature_dataset_name: str) -> list[ValidationErrorMessage]:
        errors: list[ValidationErrorMessage] = []
        if not arcpy.Exists(feature_dataset_name):
            if feature_dataset_name == config.gdb_info.required_dataset_name:
                errors.append(
                    GDBErrorMessage("Error", "ERROR:GDB:MISSING_REQUIRED_DATASET", message=f"Required feature dataset '{feature_dataset_name}' was not found.")
                )
            elif feature_dataset_name == config.gdb_info.optional_dataset_name:
                errors.append(
                    GDBErrorMessage("Notice", "NOTICE:GDB:MISSING_OPTIONAL_DATASET", message=f"Optional feature dataset '{feature_dataset_name}' was not found.")
                )
            else:
                raise ValueError(f"Feature dataset with name '{feature_dataset_name}' is not allowed.")
        return self._add_issues(errors)

    @cache
    def check_feature_class_exists(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        # Validation logic
        errors: list[ValidationErrorMessage] = []
        self._precheck(self.check_feature_dataset_exists(feature_class.dataset))
        fc_path = os.path.join(feature_class.dataset, feature_class.name)
        if not arcpy.Exists(fc_path):
            if feature_class.name in config.required_feature_class_names:
                errors.append(GDBErrorMessage("Error", "ERROR:GDB:MISSING_REQUIRED_FEATURE_CLASS", message=f"Required feature class '{feature_class:n}' was not found."))
            elif feature_class in config.optional_feature_class_names:
                errors.append(GDBErrorMessage("Error", "NOTICE:GDB:MISSING_OPTIONAL_FEATURE_CLASS", message=f"Optional feature class '{feature_class:n}' was not found."))
            else:
                raise ValueError(f"Feature class with name '{feature_class:n}' is not allowed.")

        return self._add_issues(errors)

    @cache
    def check_fields_exist(self, feature_class: NG911FeatureClass, fields: Sequence[NG911Field]) -> list[ValidationErrorMessage]:
        self._precheck(self.check_feature_class_exists(feature_class))

        errors: list[ValidationErrorMessage] = []
        existing_fields: set[arcpy.Field] = set(arcpy.ListFields(feature_class.name))
        existing_field_names: set[str] = set(field.name for field in existing_fields)
        field_names: set[str] = set(field.name for field in fields)

        if unexpected_fields_to_check := field_names - {f.name for f in feature_class.fields.values()}:
            field_list_string: str = ", ".join([f"'{name}'" for name in unexpected_fields_to_check])
            raise ValueError(f"These field(s) are not expected in feature class '{feature_class:n}': {field_list_string}")

        for nonexistent_field in field_names - existing_field_names:
            errors.append(GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:MISSING_REQUIRED_FIELD", feature_class.name, nonexistent_field, f"Field '{nonexistent_field}' is missing."))

        return self._add_issues(errors)

    @staticmethod
    def _check_coded_value_domain(gdb_domain: arcpy.da.Domain, expected_domain: NG911Domain) -> list[ValidationErrorMessage]:
        """Validation helper that, given an existing domain from a geodatabase
        and a reference domain from ``config``, checks the description of the
        domain and its coded values and descriptions. Does *not* check the name
        of the domain."""
        errors: list[ValidationErrorMessage] = []

        # Check the domain's description
        if gdb_domain.description != expected_domain.description:
            errors.append(GDBErrorMessage("Error", "ERROR:GDB:INCORRECT_DOMAIN_DESCRIPTION", message=f"Domain '{gdb_domain.name}' has description '{gdb_domain.description}', but description should be '{expected_domain.description}'."))

        gdb_entries: dict[str, str] = gdb_domain.codedValues
        gdb_codes: set[str] = {entry[0] for entry in gdb_entries.items()}
        expected_entries: FrozenDict[str, str] = expected_domain.entries
        expected_codes: set[str] = {entry[0] for entry in expected_entries.items()}

        # Handle missing and extra entries
        errors += [GDBErrorMessage("Error", "ERROR:GDB:DOMAIN_MISSING_CODE", message=f"Domain '{gdb_domain.name}' is missing code '{code}'.") for code in expected_codes - gdb_codes]
        errors += [GDBErrorMessage("Error", "ERROR:GDB:DOMAIN_EXTRA_CODE", message=f"Domain '{gdb_domain.name}' has extra code '{code}'.") for code in gdb_codes - expected_codes]

        # Check each code's description
        for expected_code, expected_description in expected_entries.items():
            if expected_code not in gdb_entries:
                continue  # Error already added above
            if expected_description != gdb_entries[expected_code]:
                errors.append(GDBErrorMessage("Error", "ERROR:GDB:DOMAIN_CODE_VALUE_MISMATCH",
                                              message=f"Domain '{gdb_domain.name}' has code '{expected_code}' with description '{gdb_entries[expected_code]}', but description should be '{expected_description}'."))

        return errors

    @staticmethod
    def _check_range_domain(gdb_domain: arcpy.da.Domain, expected_domain: NG911Domain) -> list[ValidationErrorMessage]:
        """There are no range domains prescribed by the Standards, so this
        method has not yet been implemented."""
        raise NotImplementedError
        # errors: list[ValidationErrorMessage] = []
        # return errors

    def check_gdb_domains(self) -> list[ValidationErrorMessage]:
        errors: list[ValidationErrorMessage] = []

        gdb_domains: list[arcpy.da.Domain] = arcpy.da.ListDomains(self.gdb)
        gdb_domain_names: set[str] = {d.name for d in gdb_domains}
        expected_domain_names: set[str] = {d.name for d in config.domains.values()}

        missing_domain_names: set[str] = expected_domain_names - gdb_domain_names
        extra_domain_names: set[str] = gdb_domain_names - expected_domain_names

        errors += [GDBErrorMessage("Error", "ERROR:GDB:MISSING_DOMAIN", message=f"Domain '{name}' is missing.") for name in missing_domain_names]
        errors += [GDBErrorMessage("Error", "ERROR:GDB:EXTRA_DOMAIN", message=f"Domain '{name}' is not allowed.") for name in extra_domain_names]

        for gdb_domain in gdb_domains:
            if gdb_domain.name not in expected_domain_names:
                continue  # Error already added above
            expected_domain: NG911Domain = config.domains[gdb_domain.name]

            # Check type of domain
            gdb_domain_type = gdb_domain.domainType
            expected_domain_type = DOMAIN_TYPES[expected_domain.type]
            if gdb_domain_type != expected_domain_type:
                errors.append(GDBErrorMessage("Error", "ERROR:GDB:INCORRECT_DOMAIN_TYPE", message=f"Domain '{gdb_domain.name}' is of type '{gdb_domain_type}', but should be of type '{expected_domain_type}'."))
                continue  # No use in checking the details if the type is wrong

            # Check details of domain
            if gdb_domain_type == "CodedValue":
                errors += self._check_coded_value_domain(gdb_domain, expected_domain)
            elif expected_domain_type == "Range":
                errors += self._check_range_domain(gdb_domain, expected_domain)
            else:
                raise RuntimeError(f"Unexpected domain type '{gdb_domain.domainType}'.")

        return self._add_issues(errors)

    # def _load_topology_error_df(self, error_fc: PathLike[str] | str, dataset: str) -> pd.DataFrame | None:
    #     """
    #     Given the path to a topology error feature class (that is, a feature
    #     class resulting from ``arcpy.management.ExportTopologyErrors``),
    #     returns a data frame with the following columns and ``dtype``\ s:
    #
    #     * ``OriginObjectClassName``: ``string``
    #     * ``OriginObjectClassObject``: ``object`` (:class:`~ng911ok.lib.config_dataclasses.NG911FeatureClass`)
    #     * ``OriginObjectID``: ``Int64``
    #     * ``OriginObjectNGUID``: ``string`` (NOT ``object`` (:class:`~ng911ok.lib.accessor.NGUID`))
    #     * ``DestinationObjectClassName``: ``string``
    #     * ``DestinationObjectClassObject``: ``object`` (:class:`~ng911ok.lib.config_dataclasses.NG911FeatureClass`)
    #     * ``DestinationObjectID``: ``Int64``
    #     * ``DestinationObjectNGUID``: ``string`` (NOT ``object`` (:class:`~ng911ok.lib.accessor.NGUID`))
    #     * ``RuleType``: ``string``
    #     * ``RuleDescription``: ``string``
    #     * ``RuleObject``: ``object`` (:class:`~ng911ok.lib.topology.TopologyRule`)
    #     * ``IsException``: ``boolean``
    #     * ``$Submit``: ``boolean`` (NOT ``string``; not the same as the field :ng911field:`submit`)
    #
    #     Note that the column ``IsException`` is **overwritten** with values
    #     based on each feature's :ng911field:`topoexcept` attribute.
    #
    #     :param error_fc: Path to topology error feature class
    #     :type error_fc: PathLike[str] | str
    #     :param dataset: Name of the feature dataset containing the topology
    #     :type dataset: str
    #     :return: Data frame described above, or ``None``, if the resulting
    #         data frame would be empty
    #     :rtype: pandas.DataFrame | None
    #     """
    #     fc_data_dict: dict[NG911FeatureClass, pd.DataFrame] = {}
    #     """
    #     Cache of feature class DataFrames with OBJECTID as index and columns of
    #     NGUID and :ng911field:`topoexcept` (if applicable).
    #     """
    #
    #     def __get_data_for_fc(feature_class: NG911FeatureClass, object_ids: list[int]) -> pd.DataFrame:
    #         non_na_object_ids = [oid for oid in object_ids if not pd.isna(oid)]
    #         if feature_class in fc_data_dict:
    #             return fc_data_dict[feature_class].loc[non_na_object_ids]
    #
    #         nguid: str = feature_class.unique_id.name
    #         submit: str = config.fields.submit.name
    #         topoexcept: str = topology_config.exception_field.name
    #         if topology_config.exception_field.role in feature_class.fields:
    #             fields: list[str] = ["OBJECTID", submit, topoexcept]
    #             # _df["$DangleException"] = _df[topoexcept].isin(["DANGLE_EXCEPTION", "BOTH_EXCEPTION"]).astype(pd.BooleanDtype())
    #             # _df["$InsideException"] = _df[topoexcept].isin(["INSIDE_EXCEPTION", "BOTH_EXCEPTION"]).astype(pd.BooleanDtype())
    #         else:
    #             fields: list[str] = ["OBJECTID", submit]
    #
    #         _df: pd.DataFrame = self.load_df(
    #             feature_class, fields, respect_submit=False, error_if_empty=False
    #         ).reset_index().set_index("OBJECTID")
    #
    #         _df[nguid] = _df[nguid].astype(pd.StringDtype())
    #         # _df["$Submit"] = _df[submit].replace({"Y": True, "N": False}).astype(pd.BooleanDtype())
    #         _df["$Submit"] = _df[submit].apply(lambda val: {"Y": True, "N": False}.get(val, pd.NA)).astype(pd.BooleanDtype())
    #         fc_data_dict[feature_class] = _df
    #         return _df.loc[non_na_object_ids]
    #
    #     def __assign_nguids(_df: pd.DataFrame, nguid_column: str, oid_column: str, fc_column: str) -> None:
    #         _df[nguid_column] = pd.NA
    #         _df[nguid_column] = _df[nguid_column].astype(pd.StringDtype())
    #         for fc, group in _df.groupby(fc_column, sort=False):
    #             assert isinstance(fc, NG911FeatureClass)
    #             fc_nguid_name: str = fc.unique_id.name
    #             nguid_series = __get_data_for_fc(fc, group[oid_column])[fc_nguid_name]
    #             nguid_series.drop_duplicates(keep="first", inplace=True)
    #             join_data: pd.DataFrame = _df[[oid_column]].join(nguid_series.drop_duplicates(keep="first"), on=oid_column)
    #             _df.loc[join_data.index, nguid_column] = join_data[fc_nguid_name]
    #             # _df.loc[nguid_series.index, nguid_column] = nguid_series
    #
    #     def __assign_exception(_df: pd.DataFrame, fc_column: str) -> None:
    #         topoexcept = topology_config.exception_field.name
    #         _df["IsException"] = pd.NA
    #         _df["IsException"] = _df["IsException"].astype(pd.BooleanDtype())
    #         get_exception_set: Callable[..., frozenset[str]] = topology_config.exception_mapping.get  # Might reduce the number of __get__()/__getattribute__() calls in the for loop
    #         for fc, group in _df.groupby(fc_column, sort=False):
    #             assert isinstance(fc, NG911FeatureClass)
    #             if topology_config.exception_field.role in fc.fields:
    #                 fc_df: pd.DataFrame = __get_data_for_fc(fc, group["OriginObjectID"])
    #                 fc_join: pd.DataFrame = group.join(fc_df[[topoexcept]], on="OriginObjectID", how="right").drop_duplicates()
    #                 _df.loc[group.index, "IsException"] = fc_join.apply(
    #                     lambda row: row["RuleType"] in get_exception_set(row[topoexcept], frozenset()),
    #                     axis=1
    #                 )
    #             else:
    #                 _df.loc[group.index, "IsException"] = False
    #
    #     df: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(error_fc)
    #     if df.empty:
    #         return None
    #     df["OriginObjectClassObject"] = df["OriginObjectClassName"].apply(lambda x: config.get_feature_class_by_name(x) if x else pd.NA)
    #     df.pipe(__assign_nguids, "OriginObjectNGUID", "OriginObjectID", "OriginObjectClassObject")  # Assign OriginObjectNGUID
    #     df["DestinationObjectClassObject"] = df["DestinationObjectClassName"].apply(lambda x: config.get_feature_class_by_name(x) if x else pd.NA)
    #     df.pipe(__assign_nguids, "DestinationObjectNGUID", "DestinationObjectID", "DestinationObjectClassObject")  # Assign DestinationObjectNGUID
    #     df["RuleObject"] = df.apply(
    #         lambda row: topology_config.get_rule(row["OriginObjectClassObject"], row["RuleType"], row["DestinationObjectClassObject"]),
    #         axis=1
    #     )
    #     df.pipe(__assign_exception, "OriginObjectClassObject")
    #     _logger.debug(df.to_csv(self.gdb_path.parent / "topo-df.csv"))
    #     return df

    def check_topology(self) -> list[ValidationErrorMessage]:
        """Exports topology errors to ``memory`` and evaluates results with
        respect to ``TopoExcept``."""
        self.add_and_configure_topologies(True)
        rdst_validated, odst_validated = self.validate_topologies()

        with self.required_ds_env_manager:
            topo_name: str = topology_config.required_dataset_topology_name
            out_basename: str = f"{topo_name}_Errors_{time_ns()}"
            export_result: arcpy.Result = arcpy.management.ExportTopologyErrors(topo_name, "memory", out_basename)
        error_fcs: list[str] = [export_result.getOutput(i) for i in range(export_result.outputCount)]
        # #### DEBUGGING
        # for i, error_fc in enumerate(error_fcs):
        #     arcpy.management.Delete(f"topo_errors_{i}")
        #     arcpy.conversion.ExportFeatures(error_fc, f"topo_errors_{i}")
        # #### END DEBUGGING
        _logger.debug(f"Exported topology error feature classes: {', '.join(error_fcs)}.")
        # topology_error_dfs: list[pd.DataFrame] = [
        #     tedf
        #     for fc
        #     in error_fcs
        #     if (tedf := self._load_topology_error_df(fc, config.gdb_info.required_dataset_name)) is not None
        # ]
        topology_error_fields = ["OriginObjectClassName", "OriginObjectID", "DestinationObjectClassName", "DestinationObjectID", "RuleType", "RuleDescription"]
        topology_error_dfs: list[pd.DataFrame] = [
            pd.DataFrame.spatial.from_featureclass(error_fc, fields=topology_error_fields)
            for error_fc in error_fcs
        ]

        # #### DEBUGGING
        # for __i, __error_fc in enumerate(error_fcs):
        #     arcpy.conversion.ExportFeatures(__error_fc, str(self.gdb_path / f"{out_basename}_{__i}"))
        # #### END DEBUGGING

        if topology_error_dfs:
            df: pd.DataFrame = pd.concat(topology_error_dfs)
            _logger.debug(f"Loaded topology error data frame; {len(df)} records.")
        else:
            _logger.debug(f"No topology errors loaded from error feature classes.")
            return []  # No errors in data frame => no errors to generate

        feature_class_names_present_in_topology_errors: set[str] = set(x for x in df[["OriginObjectClassName", "DestinationObjectClassName"]].values.flatten() if pd.notna(x) and x)
        submit_f = config.fields.submit
        topoexcept_f = config.fields.topoexcept
        feature_class_dfs: list[pd.DataFrame] = [
            self.load_df(
                fc := config.get_feature_class_by_name(fc_name),
                ["OID@", fc.unique_id, submit_f] + ([topoexcept_f] if topoexcept_f in fc.fields.values() else []),
                respect_submit=False
            )
            for fc_name in feature_class_names_present_in_topology_errors
        ]
        df = topology_config.enrich_error_df(df, feature_class_dfs, fill_na_submit=False)
        if self._respect_submit:
            # Drop rows where Submit is False, counting NA as False, although
            # NA values should already be accounted for with the
            # fill_na_submit=False argument passed to enrich_error_df()
            _logger.debug(f"Dropping where Submit is False or NA.")
            df.drop(df.index[~(df["OriginObjectSubmit"].fillna(False) | df["DestinationObjectSubmit"].fillna(False))], inplace=True)

        errors: list[ValidationErrorMessage] = []
        rule: TopologyRule
        group: pd.DataFrame
        for rule, group in df[~df["IsException"]].groupby("RuleType"):
            group_errors = group.apply(
                lambda row: FeatureAttributeErrorMessage(
                    severity="Error",
                    code="ERROR:GEOMETRY:TOPOLOGY",
                    layer1=oocn if pd.notna(oocn := row["OriginObjectClassName"]) else None,  # [o]rigin [o]bject [c]lass [n]ame
                    nguid1=ooid if pd.notna(ooid := row["OriginObjectNGUID"]) else None,  # [o]rigin [o]bject [ID]
                    field1="SHAPE" if pd.notna(ooid) else None,
                    value1=None,
                    layer2=docn if pd.notna(docn := row["DestinationObjectClassName"]) else None,  # [d]estination [o]bject [c]lass [n]ame
                    nguid2=doid if pd.notna(doid := row["DestinationObjectNGUID"]) else None,  # [d]estination [o]bject [ID]
                    field2="SHAPE" if pd.notna(doid) else None,
                    value2=None,
                    message=f"Feature violates topology rule '{row['RuleDescription']}'." if pd.notna([ooid, doid]).any() else f"Violation of topology rule '{row['RuleDescription']}'."
                ),
                axis=1
            ).to_list()
            _logger.debug(f"Generated {len(group_errors)} for rule {rule}.")
            errors += group_errors

        return self._add_issues(errors)

    @staticmethod
    def _check_item_spatial_reference(item: str, expected_sr: Optional[arcpy.SpatialReference] = None) -> list[ValidationErrorMessage]:
        # Get relevant information
        describe_obj = arcpy.Describe(item)
        item_sr: arcpy.SpatialReference = arcpy.convertArcObjectToPythonObject(describe_obj.spatialReference)
        item_sr_str = f"'{item_sr.name}' ({item_sr.factoryCode})" if item_sr.factoryCode else f"'{item_sr.name}'"
        if expected_sr:
            expected_sr_str = f"'{expected_sr.name}' ({expected_sr.factoryCode})" if expected_sr.factoryCode else f"'{expected_sr.name}'"
        else:
            expected_sr_str = None

        expected_sr_2d_code = config.gdb_info.spatial_reference_factory_code_2d
        expected_sr_3d_code = config.gdb_info.spatial_reference_factory_code_3d
        expected_sr_2d = arcpy.SpatialReference(expected_sr_2d_code)
        expected_sr_3d = arcpy.SpatialReference(expected_sr_3d_code)

        # Determine the appropriate error code to be used if there is an error
        error_code: Optional[GDBErrorCode]
        error_message: Optional[str] = None
        item_type: str
        if describe_obj.datasetType == "FeatureClass":
            error_code = "ERROR:FEATURE_CLASS:INCORRECT_SPATIAL_REFERENCE"
            item_type = "Feature class"
        elif describe_obj.datasetType == "FeatureDataset":
            error_code = "ERROR:DATASET:INCORRECT_SPATIAL_REFERENCE"
            item_type = "Feature dataset"
        else:
            raise ValueError(f"Unexpected dataset type '{describe_obj.datasetType}'.")

        if expected_sr:
            if item_sr == expected_sr:
                error_code = None
            else:
                error_message = f"{item_type} {item} has spatial reference {item_sr_str}, but expected {expected_sr_str}."

        else:
            if item_sr in (expected_sr_2d, expected_sr_3d):
                error_code = None
            else:
                expected_sr_2d_str = f"'{expected_sr_2d.name}' ({expected_sr_2d_code})"
                expected_sr_3d_str = f"'{expected_sr_3d.name}' ({expected_sr_3d_code})"
                error_message = f"{item_type} {item} has spatial reference {item_sr_str}, but it should be either {expected_sr_2d_str} for 2D data or {expected_sr_3d_str} for 3D data."

        if error_code:
            return [GDBErrorMessage("Error", error_code, item, message=error_message)]
        else:
            return []

    def check_dataset_spatial_reference(self, feature_dataset: str) -> list[ValidationErrorMessage]:
        describe_obj = arcpy.Describe(feature_dataset)
        if describe_obj.datasetType != "FeatureDataset":
            raise ValueError(f"Item '{feature_dataset}' is not a feature dataset.")

        errors: list[ValidationErrorMessage] = self._check_item_spatial_reference(feature_dataset)

        return self._add_issues(errors)

    @staticmethod
    def _check_single_unique_id(nguid_string: str | NAType, feature_class: NG911FeatureClass, object_id: int) -> FeatureAttributeErrorMessage | NAType:
        exc: NGUIDFormatError | None
        if pd.isna(nguid_string):
            exc = NGUIDFormatError("<Null>", "ERROR:NGUID:FORMAT", "NGUID cannot be null.")
        else:
            exc = NGUID.validate_string(nguid_string)
        if exc:
            message = f"{exc.validation_message} [See value2 for Object ID]"
            return FeatureAttributeErrorMessage("Error", exc.problem, feature_class.name, nguid_string, feature_class.unique_id.name, nguid_string, None, None, "OBJECTID", object_id, message)
        else:
            return pd.NA

    @cache
    def check_unique_id_format(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        self._precheck(self.check_fields_exist(feature_class, (feature_class.unique_id,)))

        oid_field_name: str = arcpy.Describe(feature_class.name).OIDFieldName
        nguid_field_name: str = feature_class.unique_id.name

        df: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(feature_class.name, fields=[oid_field_name, nguid_field_name])
        # error_series = df[fc_obj.unique_id.name].apply(self._check_nguid_format, feature_class_object=fc_obj)
        # error_series = df.apply(lambda row: NGUID.validate(row[nguid_field_name], feature_class, row[oid_field_name]), axis=1)
        error_series = df.apply(lambda row: self._check_single_unique_id(row[nguid_field_name], feature_class, row[oid_field_name]), axis=1)

        errors: list[ValidationErrorMessage] = error_series.dropna().tolist()

        return self._add_issues(errors)

    def check_geodatabase_for_extra_items(self) -> list[ValidationErrorMessage]:
        """Checks that there are not any extra items in the geodatabase that
        are not prescribed by the Standards."""
        errors: list[ValidationErrorMessage] = []

        rds: str = config.gdb_info.required_dataset_name
        """[r]equired [d]ata[s]et name"""

        ods: str = config.gdb_info.optional_dataset_name
        """[o]ptional [d]ata[s]et name"""

        # errors += self.check_feature_dataset_exists(config.gdb_info.required_dataset_name)

        present_datasets: set[str] = set(arcpy.ListDatasets())
        """Names of datasets present in the GDB"""

        if disallowed_datasets := present_datasets - {rds, ods}:
            errors += [GDBErrorMessage("Error", "ERROR:GDB:EXTRA_ITEM", ds_name, message=f"Dataset '{ds_name}' is not the name of a permitted feature dataset.") for ds_name in disallowed_datasets]

        rds_present_fcs: set[str]
        """Feature classes found in the GDB's required dataset"""
        if rds in present_datasets:
            with self.required_ds_env_manager:
                rds_present_fcs = set(arcpy.ListFeatureClasses())
        else:
            rds_present_fcs = set()

        ods_present_fcs: set[str]
        """Feature classes found in the GDB's optional dataset"""
        if ods in present_datasets:
            with self.optional_ds_env_manager:
                ods_present_fcs = set(arcpy.ListFeatureClasses())
                """Feature classes found in the GDB's optional dataset"""
        else:
            ods_present_fcs = set()

        rds_allowed_fcs: set[str] = set(config.required_feature_class_names)
        """Feature classes allowed in a GDB's required dataset"""

        ods_allowed_fcs: set[str] = set(config.optional_feature_class_names)
        """Feature classes allowed in a GDB's optional dataset"""

        errors += [
            GDBErrorMessage("Error", "ERROR:GDB:EXTRA_ITEM", rds_disallowed_fc, message=f"Feature class '{rds_disallowed_fc}' is not the name of a permitted feature class in dataset '{rds}'.")
            for rds_disallowed_fc in rds_present_fcs - rds_allowed_fcs
        ]

        errors += [
            GDBErrorMessage("Error", "ERROR:GDB:EXTRA_ITEM", ods_disallowed_fc, message=f"Feature class '{ods_disallowed_fc}' is not the name of a permitted feature class in dataset '{ods}'.")
            for ods_disallowed_fc in ods_present_fcs - ods_allowed_fcs
        ]

        return self._add_issues(errors)

    @staticmethod
    def _check_field_configuration(feature_class_name: str, actual_field: arcpy.Field, reference_field: NG911Field) -> list[ValidationErrorMessage]:
        """Checks the type, length, and domain of a field against reference
        data."""
        errors: list[ValidationErrorMessage] = []
        # expected_field_type = field_data_types[reference_field.type]  # Account for difference in field creation keywords specified in config.yml (e.g., "LONG") and keywords returned by arcpy.Field.type (e.g., "Integer")

        if actual_field.type != reference_field.type:
            errors.append(GDBErrorMessage("Error", "ERROR:FIELD:INCORRECT_FIELD_TYPE", feature_class_name, actual_field.name,
                                          f"Field {feature_class_name}.{actual_field.name} has type '{actual_field.type}', but should have type '{reference_field.type}'."))

        if reference_field.domain and actual_field.domain and actual_field.domain != reference_field.domain.name:
            # It should have a domain, and it does, but it's the wrong one
            errors.append(GDBErrorMessage("Error", "ERROR:FIELD:INCORRECT_FIELD_DOMAIN", feature_class_name, actual_field.name,
                                          f"Field {feature_class_name}.{actual_field.name} has domain '{actual_field.domain}', but should have domain '{reference_field.domain}'."))
        elif reference_field.domain and not actual_field.domain:
            # It should have a domain, but it doesn't
            errors.append(
                GDBErrorMessage("Error", "ERROR:FIELD:INCORRECT_FIELD_DOMAIN", feature_class_name, actual_field.name, f"Field {feature_class_name}.{actual_field.name} should be assigned the domain '{reference_field.domain.name}'."))
        elif not reference_field.domain and actual_field.domain:
            # It shouldn't have a domain, but it does
            errors.append(GDBErrorMessage("Error", "ERROR:FIELD:INCORRECT_FIELD_DOMAIN", feature_class_name, actual_field.name, f"Field {feature_class_name}.{actual_field.name} should not have a domain assigned."))

        if reference_field.length and (actual_field.length != reference_field.length):
            errors.append(GDBErrorMessage("Error", "ERROR:FIELD:INCORRECT_FIELD_LENGTH", feature_class_name, actual_field.name,
                                          f"Field {feature_class_name}.{actual_field.name} has length '{actual_field.length}', but should have length '{reference_field.length}'."))

        return errors

    @cache
    def check_feature_class_configuration(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        self._precheck(self.check_feature_class_exists(feature_class))

        errors: list[ValidationErrorMessage] = []
        desc_obj = arcpy.Describe(feature_class.name)
        fc_fields: list[arcpy.Field] = [f for f in arcpy.ListFields(feature_class.name) if not f.required]  # `if not f.required` filters out Arc-generated fields like OBJECTID and Shape_Length

        # Validate field list
        expected_fields: list[NG911Field] = list(feature_class.fields.values())
        oid_field_name: str = desc_obj.OIDFieldName
        shape_field_name: str = desc_obj.shapeFieldName
        expected_field_names: set[str] = {f.name for f in expected_fields}
        actual_field_names: set[str] = {f.name for f in fc_fields}
        missing_field_names: set[str] = expected_field_names - actual_field_names
        errors += [GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:MISSING_REQUIRED_FIELD", feature_class.name, missing_field_name, f"Feature class '{feature_class:n}' is missing required field '{missing_field_name}'.") for
                   missing_field_name in missing_field_names]
        extra_field_names: set[str] = actual_field_names - expected_field_names - {oid_field_name, shape_field_name}
        errors += [GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:EXTRA_FIELD", feature_class.name, extra_field_name, f"Feature class '{feature_class:n}' has an extra field '{extra_field_name}'.") for extra_field_name in
                   extra_field_names]

        # Validate field configuration
        for actual_field in fc_fields:
            # if actual_field.name in {oid_field_name, shape_field_name}:
            if actual_field.name not in expected_field_names:
                continue  # Error message should have already been added above
            reference_field = config.get_field_by_name(actual_field.name)
            errors += self._check_field_configuration(feature_class.name, actual_field, reference_field)

        # Validate feature/geometry type
        if (ft := desc_obj.featureType) != "Simple":
            errors.append(GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:INCORRECT_FEATURE_TYPE", feature_class.name, message=f"Feature class '{feature_class:n}' should have feature type 'Simple' instead of '{ft}'."))
        if (gt := desc_obj.shapeType.upper()) != (expected_gt := feature_class.geometry_type):
            errors.append(GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:INCORRECT_FEATURE_TYPE", feature_class.name, message=f"Feature class '{feature_class:n}' should have geometry type '{expected_gt}' instead of '{gt}'."))

        # Validate spatial reference
        errors += self._check_item_spatial_reference(feature_class.name)

        return self._add_issues(errors)

    @staticmethod
    def _check_field_against_domain(column: pd.Series, field_name: str) -> pd.Series:
        field: NG911Field = config.get_field_by_name(field_name)
        if not field.domain:
            column = column.copy()  # Avoid modifying the provided series
            column.values[:] = True  # Set all values to True
            return column.astype(bool)
        allowed_values: KeysView[str] = field.domain.entries.keys()
        if field.priority != "M":
            # If field isn't mandatory, don't look through null values
            column: pd.Series = column[column.notna()]
        return column.isin(allowed_values)

    def check_fields_against_domains(self, feature_class: NG911FeatureClass, fields: Optional[list[NG911Field]] = None) -> list[ValidationErrorMessage]:
        if fields is not None and not fields:
            raise ValueError("Falsy argument provided for 'fields'.")
        nguid: str = feature_class.unique_id.name
        fields_with_domains: list[NG911Field] = [f for f in fields or feature_class.fields.values() if f.domain]
        field_names = [f.name for f in fields_with_domains]
        self._precheck(self.check_fields_exist(feature_class, tuple(fields_with_domains)))

        # df: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(feature_class.name, fields=field_names).set_index(nguid_field_name, drop=False)
        df: pd.DataFrame = self.load_df(feature_class, fields)
        ignored_columns: list[str] = [col for col in df.columns if col not in field_names]  # Could be, e.g., SHAPE, OBJECTID
        validity: pd.DataFrame = df[field_names].apply(lambda column: self._check_field_against_domain(column, column.name))
        validity[ignored_columns] = True

        errors: list[ValidationErrorMessage] = FeatureAttributeErrorMessage.from_df(df, validity, "Error", "ERROR:DOMAIN:INVALID_VALUE", feature_class.name, lambda info: f"'{info.value1}' is not in domain '{config.get_field_by_name(info.field1).domain.name}'.")

        return self._add_issues(errors)

    @staticmethod
    def _check_esn_format(feature_class: NG911FeatureClass, column: pd.Series): ...

    def check_attributes(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        # TODO: Go through NENA and OK Standards to determine any and all specifications that can't be neatly put into domains, such as for ESN, which must be "Characters from 000 to 99999"
        # Per NENA-STA-006.2a-2022, Section 3.5, some fields must be all-uppercase
        # Should all other legacy fields be included?
        self._precheck(self.check_feature_class_configuration(feature_class))
        self._precheck(self.check_unique_id_format(feature_class))
        self._precheck(self.check_unique_id_frequency(feature_class))
        # if self._precheck(self.check_fields_against_domains(feature_class), False):
        #     arcpy.AddWarning(f"[{__class__.__name__}.check_attributes] Not all fields in {feature_class:n} match their domains.")

        df: pd.DataFrame = self.load_df(feature_class)
        errors: list[ValidationErrorMessage] = []
        cf = config.fields  # [c]onfig [f]ields to reduce verbosity
        cd = config.domains  # [c]onfig [d]omains to reduce verbosity

        #### Uppercase Check ####
        uppercase_domains: set[NG911Domain] = {f.domain for f in feature_class.fields.values() if f.domain and f.type == "TEXT"} - {cd.AGENCYID, cd.SERVICEURN}  # OK Standards v3 Section 3.07
        uppercase_fields: set[NG911Field] = {f for f in cf.values() if f.domain in uppercase_domains}
        uppercase_fields |= {cf.msagcomm, cf.msagcomm_l, cf.msagcomm_r, cf.lgcypredir, cf.lgcypretyp, cf.lgcystreet, cf.lgcytype, cf.lgcysufdir, cf.lgcyfulst,
                             cf.lgcyfuladd}  # TODO: Should all of these legacy fields be included? NENA only includes lgcystreet, lgcysufdir, lgcypredir, lgcytype.
        uppercase_fields &= {*feature_class.fields.values()}  # Filter down to only fields in this feature class
        uppercase_fields_df: pd.DataFrame = df[
            [f.name for f in feature_class.fields.values() if f in uppercase_fields]
        ]
        validity_uppercase: pd.DataFrame = uppercase_fields_df.apply(lambda col: col == col.str.upper())
        errors += FeatureAttributeErrorMessage.from_df(uppercase_fields_df, validity_uppercase, "Error", "ERROR:GENERAL:NOT_UPPERCASE", feature_class.name, lambda info: f"Values in field {info.field1} must be all-uppercase.")

        #### Mandatory Field Check ####
        mandatory_fields: list[NG911Field] = [f for f in feature_class.fields.values() if f.priority == "M"]
        fields_with_fill_values: list[NG911Field] = [f for f in feature_class.fields.values() if f.fill_value is not None]
        fill_values: dict[str, str] = {f.name: f.fill_value for f in fields_with_fill_values}

        def _generate_message(info: FeatureAttributeErrorMessage) -> str:
            nonlocal fill_values
            fill_value: GPParameterValue = fill_values.get(info.field1)
            or_blank_message: str = " or blank" if info.code == "ERROR:GENERAL:MANDATORY_IS_BLANK" else ""
            if fill_value is None:
                return f"Values in mandatory field '{info.field1}' must not be null{or_blank_message}."
            elif isinstance(fill_value, (int, float)):
                return f"Values in mandatory field '{info.field1}' must use {fill_value} instead of null{or_blank_message}."
            else:
                return f"Values in mandatory field '{info.field1}' must use '{fill_value}' instead of null{or_blank_message}."

        # Mandatory Field Check: Check mandatory fields for nulls
        mandatory_fields_df: pd.DataFrame = df[[f.name for f in mandatory_fields]]
        validity_nulls: pd.DataFrame = mandatory_fields_df.notna()
        errors += FeatureAttributeErrorMessage.from_df(mandatory_fields_df.fillna("<Null>"), validity_nulls, "Error", "ERROR:GENERAL:MANDATORY_IS_NULL", feature_class.name, _generate_message)

        # Mandatory Field Check: Check mandatory text fields for blanks (after .strip())
        mandatory_text_df = df[[f.name for f in mandatory_fields if f.type == "TEXT"]]
        validity_blanks: pd.DataFrame = mandatory_text_df.apply(lambda col: col.str.strip().astype(pd.BooleanDtype()))
        errors += FeatureAttributeErrorMessage.from_df(mandatory_text_df, validity_blanks, "Error", "ERROR:GENERAL:MANDATORY_IS_BLANK", feature_class.name, _generate_message)

        #### Extra Spaces Check ####
        text_df: pd.DataFrame = df[[f.name for f in feature_class.fields.values() if f.type == "TEXT"]]
        validity_spaces: pd.DataFrame = text_df.isna() | text_df.apply(lambda col: col.str.strip()).eq(text_df)
        errors += FeatureAttributeErrorMessage.from_df(text_df, validity_spaces, "Warning", "WARNING:GENERAL:LEADING_TRAILING_SPACE", feature_class.name, "Attribute value starts and/or ends with a blank character.")

        #### Feature-Class-Specific Check ####
        match feature_class:
            case config.feature_classes.address_point:
                topoexcept_df: pd.DataFrame = df[[cf.topoexcept.name]]
                validity_topoexcept: pd.DataFrame = topoexcept_df.isin(topology_config.address_point_allowed_values)
                errors += FeatureAttributeErrorMessage.from_df(topoexcept_df, validity_topoexcept, "Error", "ERROR:GENERAL:INVALID_VALUE", config.feature_classes.address_point.name, lambda info: f"'{info.value1}' is not valid for {info.field1} in {info.layer1}.")

        return self._add_issues(errors)

    @cache
    def check_unique_id_frequency(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        self._precheck(self.check_fields_exist(feature_class, (feature_class.unique_id,)))

        oid_field_name: str = arcpy.Describe(feature_class.name).OIDFieldName
        nguid_field_name: str = feature_class.unique_id.name
        df: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(feature_class.name, fields=[oid_field_name, nguid_field_name])
        null_nguid_df: pd.DataFrame = df[df[nguid_field_name].isna()]
        blank_nguid_df: pd.DataFrame = df[df[nguid_field_name].str.strip().eq("")]
        duplicate_nguid_df: pd.DataFrame = df[df[nguid_field_name].duplicated(keep=False)]

        if sum(map(len, [null_nguid_df, blank_nguid_df, duplicate_nguid_df])) == 0:
            return []

        errors: list[ValidationErrorMessage] = []

        for nguid, group in duplicate_nguid_df.groupby(nguid_field_name):  # type: str, pd.DataFrame
            count: int = len(group)
            errors += group.apply(lambda row: FeatureAttributeErrorMessage(
                "Error", "ERROR:NGUID:DUPLICATE",
                feature_class.name, nguid, nguid_field_name, nguid,
                None, None, oid_field_name, row[oid_field_name],
                f"Feature has non-unique NGUID. [See value2 for Object ID]"
            ), axis=1).tolist()

        errors += null_nguid_df.apply(lambda row: FeatureAttributeErrorMessage(
            "Error", "ERROR:GENERAL:MANDATORY_IS_NULL",
            feature_class.name, row[nguid_field_name], nguid_field_name, "<Null>",
            None, None, oid_field_name, row[oid_field_name],
            f"Values in mandatory field '{nguid_field_name}' must not be null or blank. [See value2 for Object ID]",
        ), axis=1).tolist()

        errors += blank_nguid_df.apply(lambda row: FeatureAttributeErrorMessage(
            "Error", "ERROR:GENERAL:MANDATORY_IS_BLANK",
            feature_class.name, row[nguid_field_name], nguid_field_name, row[nguid_field_name],
            None, None, oid_field_name, row[oid_field_name],
            f"Values in mandatory field '{nguid_field_name}' must not be null or blank. [See value2 for Object ID]",
        ), axis=1).tolist()

        return self._add_issues(errors)

    def check_address_frequency(self) -> list[ValidationErrorMessage]:
        # Not to be confused with address range overlap check for RCL
        self._precheck(self.check_feature_class_exists(config.feature_classes.address_point))
        apf = config.feature_classes.address_point.fields
        address_fields: list[NG911Field] = [apf.addnumpre, apf.addnumber, apf.addnumsuf, apf.predir, apf.premod, apf.pretype, apf.pretypesep, apf.street, apf.streettype, apf.sufmod, apf.sufdir]
        self._precheck(self.check_fields_exist(config.feature_classes.address_point, tuple(address_fields)))

        address_field_names: list[str] = [f.name for f in address_fields]
        df: pd.DataFrame = self.load_df(config.feature_classes.address_point, fields=[apf.nguid_add, *address_fields, apf.fulladdr])
        df["_Concatenated_Address_Fields_"] = df.apply(lambda row: " ".join(str(row[col]) for col in address_field_names), axis=1)
        df.drop(columns=address_field_names, inplace=True)
        validity: pd.DataFrame = df.apply(lambda col: col.duplicated(keep=False))
        validity[apf.nguid_add.name] = True  # Set NGUID validity to True; prevent generating errors for that column in this check
        errors: list[ValidationErrorMessage] = FeatureAttributeErrorMessage.from_df(df, validity, "Error", "ERROR:ADDRESS:DUPLICATE", config.feature_classes.address_point.name, "Addresses must be unique.")

        return self._add_issues(errors)

    def check_submission_counts(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        errors: list[ValidationErrorMessage] = []

        count: int = self.count(feature_class, f"{config.fields.submit:n} = 'Y'")
        if count == 0:
            errors.append(GDBErrorMessage("Error", "ERROR:FEATURE_CLASS:EMPTY_SUBMISSION", feature_class.name,
                                          message=f"Feature class has no features marked for submission, i.e., no features have their '{config.fields.submit:n}' attribute set to 'Y'."))

        return self._add_issues(errors)

    @staticmethod
    def _get_parity_indexer(df: pd.DataFrame, parity: Parity, side: Literal["L", "R"]) -> tuple[pd.Series, list[str]]:
        """Returns an indexer (2-tuple to be used with ``DataFrame.loc``) for a
        ``DataFrame`` of road centerline attributes. Rows are filtered down to
        those where the *Parity_\** field is equivalent to *parity*, and the
        included columns are the From-Address and To-Address columns that
        correspond to *side*."""
        rclf = config.feature_classes.road_centerline.fields
        row_indexer: pd.Series
        col_indexer: list[str]
        if side == "L":
            row_indexer = cast(pd.Series, df[rclf.parity_l.name] == parity.value)
            col_indexer = [rclf.add_l_from.name, rclf.add_l_to.name]
        elif side == "R":
            row_indexer = cast(pd.Series, df[rclf.parity_r.name] == parity.value)
            col_indexer = [rclf.add_r_from.name, rclf.add_r_to.name]
        else:
            raise ValueError("Argument for 'side' must be either 'L' or 'R'.")
        return row_indexer, col_indexer

    def check_parities(self) -> list[ValidationErrorMessage]:
        rcl = config.feature_classes.road_centerline
        rclf = rcl.fields
        self._precheck(self.check_fields_against_domains(rcl, [rclf.parity_l, rclf.parity_r]))

        df_fields: list[NG911Field] = [rcl.unique_id, rclf.parity_l, rclf.add_l_from, rclf.add_l_to, rclf.parity_r, rclf.add_r_from, rclf.add_r_to]
        df: pd.DataFrame = self.load_df(rcl, fields=df_fields)
        nguid, parity_l, add_l_from, add_l_to, parity_r, add_r_from, add_r_to = [f.name for f in df_fields]
        error_df: pd.DataFrame = df.applymap(lambda _: pd.NA).astype(pd.StringDtype())
        """Data frame upon which a validity DF will be based. Rather than using
        boolean values, this DF uses error codes, as this function generates
        errors with several different codes. Empty strings indicate no error,
        and ``pd.NA`` indicates that a values has not yet been validated."""
        error_df[nguid] = ""  # Trust that NGUID is valid
        error_df.loc[:, [parity_l, parity_r]] = df[[parity_l, parity_r]].isin([x.value for x in Parity]).replace({True: "", False: "ERROR:PARITY:INVALID"})  # Make sure Parity_X values are in fact valid parities

        # Where the parity attribute itself is invalid, only check the from/to attributes for nulls
        invalid_left_parity_indexer = (error_df[parity_l].eq("ERROR:PARITY:INVALID"), [add_l_from, add_l_to])
        error_df.loc[invalid_left_parity_indexer] = error_df.loc[invalid_left_parity_indexer].notna().replace({True: "", False: "ERROR:PARITY:NULL"})
        invalid_right_parity_indexer = (error_df[parity_r].eq("ERROR:PARITY:INVALID"), [add_r_from, add_r_to])
        error_df.loc[invalid_right_parity_indexer] = error_df.loc[invalid_right_parity_indexer].notna().replace({True: "", False: "ERROR:PARITY:NULL"})

        parity: Parity
        side: Literal["L", "R"]
        for parity, side in product(Parity, ["L", "R"]):
            # Iterate over each combination of parity and side
            # If Parity_X is invalid, then Add_X_From and Add_X_To will have NA validity
            indexer = self._get_parity_indexer(df, parity, side)
            subset: pd.DataFrame = df.loc[indexer].astype(pd.Int64Dtype())
            subset_validity: pd.DataFrame
            if parity is Parity.EVEN:
                subset_validity = subset.gt(0) & subset.mod(2).eq(0)
                subset_validity = subset_validity.applymap(lambda val: "" if val is True else "ERROR:PARITY:MISMATCH" if val is False else pd.NA)
            elif parity is Parity.ODD:
                subset_validity = subset.gt(0) & subset.mod(2).eq(1)
                subset_validity = subset_validity.applymap(lambda val: "" if val is True else "ERROR:PARITY:MISMATCH" if val is False else pd.NA)
                # subset_validity.replace({True: "", False: "ERROR:PARITY:MISMATCH"}, inplace=True)
            elif parity is Parity.BOTH:
                subset_validity = subset.gt(0)
                subset_validity = subset_validity.applymap(lambda val: "" if val is True else "ERROR:PARITY:EXPECTED_NONZERO" if val is False else pd.NA)
                # subset_validity.replace({True: "", False: "ERROR:PARITY:EXPECTED_NONZERO"}, inplace=True)
            elif parity is Parity.ZERO:
                subset_validity = subset.eq(0)
                subset_validity = subset_validity.applymap(lambda val: "" if val is True else "ERROR:PARITY:EXPECTED_ZERO" if val is False else pd.NA)
                # subset_validity.replace({True: "", False: "ERROR:PARITY:EXPECTED_ZERO"}, inplace=True)
            else:
                raise RuntimeError(f"Unexpected parity: '{parity}'")
            subset_validity.fillna("ERROR:PARITY:NULL", inplace=True)
            error_df.loc[indexer] = subset_validity

        # At this point, there should be no NA/null values in error_df.
        # However, the from/to values that correspond to invalid Parity_X attributes will all have ERROR:PARITY:NULL
        # errors set, even if they aren't null. Since the parities themselves are invalid in those cases, remove the
        # errors from the from/to values unless they are null themselves:
        val_and_err_df: pd.DataFrame = error_df.join(df, lsuffix="_err", rsuffix="_val")
        for parity_field, address_field in [
            (parity_l, add_l_from), (parity_l, add_l_to), (parity_r, add_r_from), (parity_r, add_r_to)
        ]:
            parity_err: str = f"{parity_field}_err"
            addr_err: str = f"{address_field}_err"
            addr_val: str = f"{address_field}_val"
            error_df.loc[:, address_field] = val_and_err_df.apply(
                lambda row: "" if row[parity_err] == "ERROR:PARITY:INVALID" and row.notna()[addr_val] else row[addr_err],
                axis=1
            )

        # Iterate over tuples containing the possible error codes and appropriate message functions
        errors: list[ValidationErrorMessage] = []
        for error_code, error_message_function in [
            ("ERROR:PARITY:EXPECTED_ZERO", lambda info: f"Parity is zero, but {info.field1} is {info.value1}."),
            ("ERROR:PARITY:EXPECTED_NONZERO", lambda info: f"Parity is nonzero, but {info.field1} is {info.value1}."),
            ("ERROR:PARITY:MISMATCH", lambda info: f"{info.field1} value of {info.value1} does not match the corresponding parity attribute."),
            ("ERROR:PARITY:INVALID", lambda info: f"'{info.value1}' is not a valid parity."),
            ("ERROR:PARITY:NULL", lambda info: f"{info.field1} must be an integer greater than or equal to 0.")
        ]:
            errors += FeatureAttributeErrorMessage.from_df(df, error_df.ne(error_code), "Error", error_code, rcl.name, error_message_function)

        return self._add_issues(errors)

        # validity: pd.DataFrame = error_df.ne("ERROR:PARITY:MISMATCH")
        # """Data frame to populate with boolean values based on ``error_df`` to
        # be passed to ``FeatureAttributeErrorMessage.from_df()``.
        #
        # The data frame is initialized to flag address-parity mismatch errors by
        # comparing the values of ``error_df`` to the corresponding error."""
        #
        # # Only rows where the actual parity attribute is valid are selected via ``error_df[parity_l].eq("")``
        # parity_validity: pd.Series = error_df[parity_l].eq("")
        # valid_parity_df: pd.DataFrame = df.loc[parity_validity]
        # """Contains the data for the left side, excluding rows where the left-
        # side parity attribute is invalid."""
        # errors += FeatureAttributeErrorMessage.from_df(valid_parity_l_df, validity.loc[left_indexer], "Error", "ERROR:PARITY:MISMATCH", rcl.name, lambda info: f"Address number {info.value1} does not match parity in field '{parity_l}'.")

        # # Generate left-side address-parity mismatch errors
        # # Only rows where the actual parity attribute is valid are selected via ``error_df[parity_l].eq("")``
        # left_indexer: tuple[pd.Series, list[str]] = (error_df[parity_l].eq(""), [nguid, parity_l, add_l_from, add_l_to])
        # valid_parity_l_df: pd.DataFrame = df.loc[left_indexer]
        # """Contains the data for the left side, excluding rows where the left-
        # side parity attribute is invalid."""
        # errors += FeatureAttributeErrorMessage.from_df(valid_parity_l_df, validity.loc[left_indexer], "Error", "ERROR:PARITY:MISMATCH", rcl.name, lambda info: f"Address number {info.value1} does not match parity in field '{parity_l}'.")
        #
        # # Generate right-side address-parity mismatch errors
        # right_indexer: tuple[pd.Series, list[str]] = (error_df[parity_l].eq(""), [nguid, parity_r, add_r_from, add_r_to])
        # valid_parity_r_df: pd.DataFrame = df.loc[right_indexer]
        # """Contains the data for the right side, excluding rows where the
        # right-side parity attribute is invalid."""
        # errors += FeatureAttributeErrorMessage.from_df(valid_parity_r_df, validity.loc[right_indexer], "Error", "ERROR:PARITY:MISMATCH", rcl.name, lambda info: f"Address number {info.value1} does not match parity in field '{parity_r}'.")

        # Generate Parity_X attribute errors
        # validity = error_df.ne("ERROR:PARITY:INVALID")
        # parity_attr_df: pd.DataFrame = df.loc[:, [nguid, parity_l, parity_r]]
        # """Contains the parity attribute data. This will be used to generate
        # validation errors for features where the parity attribute itself was
        # invalid, meaning that the from/to attributes could not be validated
        # against an expected parity."""
        # errors += FeatureAttributeErrorMessage.from_df(parity_attr_df, validity.loc[:, nguid, parity_l, parity_r], "Error", "ERROR:PARITY:INVALID", rcl.info, lambda info: f"Value '{info.value1}' is not valid for field '{info.field1}'.")
        #
        # return errors

    def check_next_gen_against_legacy(self) -> list[ValidationErrorMessage]:
        errors: list[ValidationErrorMessage] = []
        legacy_file_path: str = os.path.join(dirname(dirname(dirname(__file__))), "legacy.yml")
        with open(legacy_file_path, "r") as legacy_file:
            ng_to_lgcy: dict[str, dict] = yaml.safe_load(legacy_file)

        lgcy_field_infos: list[LegacyFieldInfo] = [LegacyFieldInfo(**info) for info in ng_to_lgcy["fields"]]

        field_pairs: list[tuple[NG911Field, NG911Field]] = [(config.fields[info.next_gen.role], config.fields[info.legacy.role]) for info in lgcy_field_infos]
        """List of tuples each containing 2 ``NG911Field`` objects. The first (index 0) item is a Next-Generation field; the second (index 1) is the corresponding legacy field."""

        ng_lgcy_fields: set[NG911Field] = {f[0] for f in field_pairs} | {f[1] for f in field_pairs}
        ng_lgcy_field_names: set[str] = {f.name for f in ng_lgcy_fields}

        fc: NG911FeatureClass
        for fc in config.feature_classes.values():
            fields_to_check = [f for f in fc.fields if f in ng_lgcy_fields]
            if not fields_to_check:
                # No legacy fields in this feature class; move on to next
                continue

            self._precheck(self.check_feature_class_exists(fc))
            self._precheck(self.check_unique_id_frequency(fc))
            self._precheck(self.check_fields_against_domains(fc, [f for f in fields_to_check if f.domain]))
            nguid: str = fc.unique_id.name
            field_names_to_check: list[str] = [f.name for f in fields_to_check]
            df: pd.DataFrame = self.load_df(fc, field_names_to_check)
            # validity: pd.DataFrame = df.applymap(lambda _: pd.NA)
            # validity[nguid] = True  # Trust that NGUIDs are valid

            for lgcy_field_info in lgcy_field_infos:
                ng_field = lgcy_field_info.next_gen
                lgcy_field = lgcy_field_info.legacy
                ng_name: str = ng_field.name
                lgcy_name: str = lgcy_field.name
                if {ng_name, lgcy_name} - set(field_names_to_check):
                    # These particular fields are not in this feature class
                    continue
                validity: pd.Series = lgcy_field_info.compare_columns(df)
                message_function: Callable[[FeatureAttributeErrorMessage], str]
                if lgcy_field_info.value_map:
                    message_function = lambda info: f"Expected '{lgcy_field_info.value_map[info.value1]} for {info.field2}, but got '{info.value2}'."
                else:
                    message_function = lambda info: f"{info.field2} attribute does not correctly correspond to {info.field1} attribute."
                errors += FeatureAttributeErrorMessage.from_df_two_fields(df, validity, ng_name, lgcy_name, "Error", "ERROR:LEGACY:MISMATCH", fc.name, message_function)

        return self._add_issues(errors)

    def check_addresses_against_roads(self) -> list[ValidationErrorMessage]:
        """
        Joins road centerline to address point based on AP's RCLMatch
        attribute, then checks if each of the following is true:

        1. RCLMatch corresponds to existing NGUID_RDCL
        2. RCLSide and address parity are consistent with exactly one side of
            matching road
        3. *** DISABLED *** Each road which corresponds to an AP's RCLMatch has a GeoMSAG value
            of ``N`` w.r.t. AP's RCLSide
        4. AP's MSAG Community corresponds to road's MSAG Community w.r.t.
            AP's RCLSide
        5. AP's street name fields correspond to those of matching road
        6. AP's address number is within matching road's range (w.r.t. RCLSide)
        """
        street_fields: FrozenList[NG911Field] = config.street_fields

        ap = config.feature_classes.address_point
        apf = ap.fields
        ap_df_fields = [ap.unique_id, apf.rclmatch, apf.rclside, apf.msagcomm, apf.addnumber, *street_fields]
        ap_street_column_names = [f.name for f in street_fields]
        ap_nguid, rclmatch, rclside, msagcomm_ap, addnumber = (f.name for f in ap_df_fields[:5])

        rcl = config.feature_classes.road_centerline
        rclf = rcl.fields
        rcl_df_fields = [rcl.unique_id, rclf.add_l_from, rclf.add_l_to, rclf.add_r_from, rclf.add_r_to, rclf.parity_l, rclf.parity_r, rclf.msagcomm_l, rclf.msagcomm_r, rclf.geomsag_l, rclf.geomsag_r, *street_fields]
        rcl_street_column_names = [f"{f:n}_rcl" if f in ap_street_column_names else f.name for f in street_fields]
        rcl_nguid, add_l_from, add_l_to, add_r_from, add_r_to, parity_l, parity_r, msagcomm_l, msagcomm_r, geomsag_l, geomsag_r = [f"{f:n}_rcl" if f in ap_df_fields else f.name for f in rcl_df_fields[:11]]  # (f"{f.name}_rcl" for f in rcl_df_fields[:9])

        self._precheck(self.check_feature_class_exists(ap))
        self._precheck(self.check_fields_exist(ap, tuple(ap_df_fields)))
        self._precheck(self.check_feature_class_exists(rcl))
        self._precheck(self.check_fields_exist(rcl, tuple(rcl_df_fields)))
        # TODO: Add precheck for address range consistency and possibly check_attributes

        del street_fields, apf, rclf  # No longer needed; avoid accidental usage below

        errors: list[ValidationErrorMessage] = []

        ap_df: pd.DataFrame = self.load_df(ap, ap_df_fields).dropna(subset=[rclmatch])
        """:ng911fc:`address_point` data frame without features with null RCLMatch attributes. So long as RCLMatch is Mandatory, validation errors should be produced for those cases by ``check_attributes``."""

        rcl_df: pd.DataFrame = self.load_df(rcl, rcl_df_fields)
        """:ng911fc:`road_centerline` data frame."""

        joined_df: pd.DataFrame = ap_df.join(rcl_df, rclmatch, "left", rsuffix="_rcl")
        """:ng911fc:`address_point` data frame to which road centerlines have been joined based on :ng911field:`rclmatch`. Address points with null RCLMatch attributes have been removed. Columns from road centerline have the suffix '_rcl', e.g., 'StreetType_rcl'."""

        del ap_df_fields, rcl_df_fields, ap_df, rcl_df  # No longer needed; avoid accidental usage below

        #### [1] CONFIRM RCLMATCH CORRESPONDS TO AN ACTUAL ROAD NGUID ####

        no_match_df: pd.DataFrame = joined_df.loc[joined_df[rcl_nguid].isna(), [ap_nguid, rclmatch]]
        validity: pd.DataFrame = no_match_df.applymap(lambda _: True)
        no_match_df[rclmatch] = False
        """Subset of ``joined_df`` where :ng911field:`rclmatch` referenced a nonexistent :ng911fc:`road_centerline` NGUID."""
        errors += FeatureAttributeErrorMessage.from_df(no_match_df, validity, "Error", "ERROR:GEOCODE:UNKNOWN_MATCH", ap.name, lambda info: f"No feature exists in {rcl:n} with {rcl_nguid} '{info.value1}'.")

        match_df: pd.DataFrame = joined_df.loc[joined_df[rcl_nguid].notna(), :]
        """Subset of ``joined_df`` where the join succeeded."""

        del joined_df, validity, no_match_df  # No longer needed; avoid accidental usage below

        #### [2] CONFIRM RCLSIDE AND ADDRESS PARITY CORRESPOND TO CORRECT SIDE OF ROAD ####

        matched_on_left = match_df[rclside] == "LEFT"
        matched_on_right = match_df[rclside] == "RIGHT"

        # Compute parity of the numeric address field; using $ in the column name should prevent conflicts
        match_df["$ap_parity"] = match_df[addnumber].apply(calculate_parity).apply(str)

        # Get boolean series indicating if AP parity matches RCL's Parity_L or Parity_R
        ap_parity_on_left = (match_df[parity_l] == match_df["$ap_parity"]) | (match_df[parity_l] == Parity.BOTH.value)
        ap_parity_on_right = (match_df[parity_r] == match_df["$ap_parity"]) | (match_df[parity_r] == Parity.BOTH.value)

        correct_left = ap_parity_on_left & matched_on_left
        correct_right = ap_parity_on_right & matched_on_right

        wrong_side_df: pd.DataFrame = match_df[~(correct_left | correct_right)]
        """Subset of ``match_df`` where the address parity and RCLSide are not consistent with road parity."""
        errors += wrong_side_df.apply(
            lambda row: FeatureAttributeErrorMessage(
                "Error", "ERROR:GEOCODE:WRONG_SIDE",
                ap.name, row[ap_nguid], rclside, row[rclside],
                rcl.name, row[rcl_nguid], None, None,
                f"Address point feature's '{rclside}' attribute is not consistent with matched road centerline feature's parity attributes ({parity_l} = '{row[parity_l]}' / {parity_r} = '{row[parity_r]}')."
            ),
            axis=1).to_list()

        both_sides_df: pd.DataFrame = match_df[correct_left & correct_right]
        """Subset of ``match_df`` where the address parity and RCLSide match BOTH sides of road based on parity."""
        errors += both_sides_df.apply(
            lambda row: FeatureAttributeErrorMessage(
                "Error", "ERROR:GEOCODE:BOTH_SIDES",
                ap.name, row[ap_nguid], rclside, row[rclside],
                rcl.name, row[rcl_nguid], None, None,
                f"Address point feature's '{rclside}' attribute matches both sides of matching road centerline feature ({parity_l} = '{row[parity_l]}' / {parity_r} = '{row[parity_r]}')."
            ),
            axis=1).to_list()

        valid_matches_df: pd.DataFrame = match_df[correct_left ^ correct_right]
        """Subset of ``match_df`` where each address matched one road on one side."""

        # # [3] Check GeoMSAG
        # geomsag_left_error: pd.Series = valid_matches_df[rclside].eq("LEFT") & valid_matches_df[geomsag_l].eq("Y")
        # geomsag_right_error: pd.Series = valid_matches_df[rclside].eq("RIGHT") & valid_matches_df[geomsag_l].eq("Y")
        #
        # def _generate_error(row, side):
        #     road_error_field_name: str = {"LEFT": msagcomm_l, "RIGHT": msagcomm_r}[side]
        #     road_error_field_value: str = row[road_error_field_name]
        #     return FeatureAttributeErrorMessage(
        #         "Error", "ERROR:",
        #         ap.name, row[ap_nguid], msagcomm_ap, row[msagcomm_ap],
        #         rcl.name, row[rcl_nguid], road_error_field_name, road_error_field_value,
        #         f"Address point feature's '{msagcomm_ap}' attribute is not the same as matching road centerline feature's '{road_error_field_name}' attribute."
        #     )
        #
        # errors += valid_matches_df[geomsag_left_error].apply(_generate_error, side="LEFT", axis=1).to_list()
        # errors += valid_matches_df[geomsag_right_error].apply(_generate_error, side="RIGHT", axis=1).to_list()

        #### CONFIRM MSAG COMMUNITY, STREET NAME, AND ADDRESS RANGE MATCH ####

        # [4] Check if AP's MSAG Community corresponds to that of matching road, w.r.t. RCLSide
        ap_matched_left_name = matched_on_left & (match_df[msagcomm_ap] == match_df[msagcomm_l])
        ap_matched_right_name = matched_on_right & (match_df[msagcomm_ap] == match_df[msagcomm_r])

        def _generate_error(row):
            side: str = row[msagcomm_ap]
            road_error_field_name: str = {"LEFT": msagcomm_l, "RIGHT": msagcomm_r}[side]
            road_error_field_value: str = row[road_error_field_name]
            return FeatureAttributeErrorMessage(
                "Error", "ERROR:GEOCODE:WRONG_COMMUNITY",
                ap.name, row[ap_nguid], msagcomm_ap, row[msagcomm_ap],
                rcl.name, row[rcl_nguid], road_error_field_name, road_error_field_value,
                f"Address point feature's '{msagcomm_ap}' attribute is not the same as matching road centerline feature's '{road_error_field_name}' attribute."
            )

        if not (new_errors := match_df[~(ap_matched_left_name | ap_matched_right_name)].apply(_generate_error, axis=1)).empty:
            errors += new_errors.to_list()

        # [5] Check if AP's street name corresponds to matching road's name
        # ap_matched_name = match_df[ap_street_column_names].values == match_df[rcl_street_column_names].values
        # def _generate_error(row):
        #     return FeatureAttributeErrorMessage(
        #         "Error", "ERROR:GEOCODE:NAME_MISMATCH",
        #         ap.name, row[ap_nguid],
        #     )
        # errors += match_df[~ap_matched_name].apply(_generate_error, axis=1).to_list()
        # validity = match_df.applymap(lambda _: True)
        # validity.loc[:, ap_street_column_names] = ap_matched_name
        column_pairs: dict[str, str] = {_ap_field: _rcl_field for _ap_field, _rcl_field in zip(ap_street_column_names, rcl_street_column_names)}
        errors += FeatureAttributeErrorMessage.from_joined_df(match_df, column_pairs, True, "Error", "ERROR:GEOCODE:NAME_MISMATCH", ap.name, rcl.name, rcl_nguid, "Street name fields must match.")

        # [6] Check if address number is in range of matching road, w.r.t. RCLSide
        ap_under_left_from = matched_on_left & (match_df[addnumber] < match_df[add_l_from])
        ap_over_left_to = matched_on_left & (match_df[addnumber] > match_df[add_l_to])
        ap_under_right_from = matched_on_right & (match_df[addnumber] < match_df[add_r_from])
        ap_over_right_to = matched_on_right & (match_df[addnumber] > match_df[add_r_to])

        def _generate_error(row, *, comparison: Literal["less", "greater"], road_error_field_name: str):
            return FeatureAttributeErrorMessage(
                "Error", "ERROR:GEOCODE:OUT_OF_RANGE",
                ap.name, row[ap_nguid], addnumber, row[addnumber],
                rcl.name, row[rcl_nguid], road_error_field_name, row[road_error_field_name],
                lambda info: f"Address feature's {addnumber} ({info.value1}) is {comparison} than matching road centerline feature's {road_error_field_name} ({row[road_error_field_name]}).")

        errors += match_df[ap_under_left_from].apply(_generate_error, axis=1, comparison="less", road_error_field_name=add_l_from).to_list()
        errors += match_df[ap_over_left_to].apply(_generate_error, axis=1, comparison="greater", road_error_field_name=add_l_to).to_list()
        errors += match_df[ap_under_right_from].apply(_generate_error, axis=1, comparison="less", road_error_field_name=add_r_from).to_list()
        errors += match_df[ap_over_right_to].apply(_generate_error, axis=1, comparison="greater", road_error_field_name=add_r_to).to_list()

        return self._add_issues(errors)

    def _check_spatial_attribute_consistency(
            self,
            left_feature_class: NG911FeatureClass,
            relationship: Literal["intersects", "within", "contains"],
            right_feature_class: NG911FeatureClass,
            fields: list[NG911Field],
            error_severity: Severity,
            error_code: FeatureAttributeErrorCode,
            error_message: str | Callable[[FeatureAttributeErrorMessage], str],
            left_df: Optional[pd.DataFrame] = None,
            right_df: Optional[pd.DataFrame] = None
    ) -> list[FeatureAttributeErrorMessage]:
        errors: list[FeatureAttributeErrorMessage] = []
        left_df = left_df or self.load_df(left_feature_class, fields)
        right_df = right_df.copy() or self.load_df(right_feature_class, fields)
        assert isinstance(left_df.spatial, GeoAccessor)  # Helps autocomplete

        # for _df, _fc in ((left_df, left_feature_class), (right_df, right_feature_class)):
        #     if _df.empty:
        #         message = f"Could not check consistency; {_fc.name} has no features."
        #         if self._respect_submit:
        #             message += " Does it have any features marked for submission?"
        #         errors.append(FeatureAttributeErrorMessage.one_feature(error_severity, error_code, _fc.name, "", "", None, message))
        self._precheck(self._check_for_empty_df(left_df))
        self._precheck(self._check_for_empty_df(right_df))

        column_pairs: dict[str, str] = {f.name: f"{f:n}_right" for f in fields}
        right_df.rename(columns=column_pairs)

        joined_df: pd.DataFrame = left_df.spatial.join(right_df, "left", relationship, right_tag="right")  # Note that an underscore is automatically inserted before right_tag
        errors += FeatureAttributeErrorMessage.from_joined_df(joined_df, column_pairs, True, error_severity, error_code, left_feature_class.name, right_feature_class.name, right_feature_class.unique_id.name, error_message)
        return errors

    def check_address_point_esn(self) -> list[ValidationErrorMessage]:
        ap = config.feature_classes.address_point
        esz = config.feature_classes.esz_boundary
        esn = config.fields.esn.name
        ap_df: pd.DataFrame = self.load_df(ap, [esn, ap.unique_id.name])
        ap_df.rename(columns=lambda col: f"{col}_ap", inplace=True)
        esz_df: pd.DataFrame = self.load_df(esz, [esn, esz.unique_id.name])
        esz_df.rename(columns=lambda col: f"{col}_esz", inplace=True)

        esn_ap = f"{ap.fields.esn:n}_ap"
        esn_esz = f"{esz.fields.esn:n}_esz"
        shape_ap = ap_df.spatial.name
        shape_esz = esz_df.spatial.name
        nguid_ap = f"{ap.unique_id:n}_ap"

        # joined_df: pd.DataFrame = ap_df.join(
        #     esz_df.set_index(esn_esz),
        #     on=esn_ap,
        #     validate="m:1"
        # )
        joined_df: pd.DataFrame = ap_df.merge(
            esz_df,
            left_on=esn_ap,
            right_on=esn_esz,
            validate="m:1"
        )
        joined_df["$matches_esz_esn"] = joined_df.apply(
            lambda row: row[shape_ap].within(row[shape_esz], "PROPER") if row[[shape_ap, shape_esz]].notna().all() else pd.NA,
            axis=1
        ).astype(pd.BooleanDtype())

        wrong_esn_df: pd.DataFrame = joined_df[~joined_df["$matches_esz_esn"]].dropna(subset="$matches_esz_esn").rename(columns={esn_ap: esn})  # Where AP's ESN doesn't match that of the ESN in which it lies; also, rename esn_ap column back to its original name for the sake of populating Field1 and Field2
        no_match_esn_df: pd.DataFrame = joined_df[joined_df["$matches_esz_esn"].isna()].rename(columns={esn_ap: esn})  # Where AP isn't within an ESZ at all

        # wrong_esn_df: pd.DataFrame = joined_df.rename(columns={esn_ap: esn})  # Rename esn_ap column back to its original name for the sake of populating Field1 and Field2

        errors: list[ValidationErrorMessage] = (
                wrong_esn_df.apply(lambda row: FeatureAttributeErrorMessage.one_feature(
                    "Error", "ERROR:CONSISTENCY:ADDRESS_ESN",
                    ap.name, row[nguid_ap], esn, row[esn],
                    f"Address point's '{esn}' does not match that of the ESZ polygon in which it lies."
                ), axis=1).to_list() +
            # FeatureAttributeErrorMessage.from_joined_df(
            #     wrong_esn_df,
            #     {esn: esn_esz},
            #     True,
            #     "Error",
            #     "ERROR:CONSISTENCY:ADDRESS_ESN",
            #     ap.name,
            #     esz.name,
            #     f"{esz.unique_id:n}_esz",
            #     f"Address point's '{esn}' does not match that of the ESZ polygon in which it lies."
            # ) +
                no_match_esn_df.apply(lambda row: FeatureAttributeErrorMessage.one_feature(
                    "Error", "ERROR:CONSISTENCY:ADDRESS_ESN",
                    ap.name, row[nguid_ap], esn, row[esn],
                    f"No ESZ polygon with ESN '{row[esn]}'."
                ), axis=1).to_list()
        )

        return self._add_issues(errors)

        # return self._add_issues(self._check_spatial_attribute_consistency(
        #     config.feature_classes.address_point, "within", config.feature_classes.esz_boundary,
        #     [config.fields.esn], "Error", "ERROR:CONSISTENCY:ADDRESS_ESN",
        #     f"Address point's '{esn}' does not match that of the ESZ polygon in which it lies."
        # ))

    def check_road_esn(self) -> list[ValidationErrorMessage]:
        rcl = config.feature_classes.road_centerline
        esz = config.feature_classes.esz_boundary
        da = config.feature_classes.discrepancyagency_boundary

        rcl_describe: dict = arcpy.da.Describe(self.str_path_to(rcl))
        oid: str = rcl_describe["OIDFieldName"]

        cf = config.fields
        nguid_rdcl = rcl.unique_id.name
        nguid_esz = esz.unique_id.name
        esn = cf.esn.name
        esn_l = cf.esn_l.name
        esn_r = cf.esn_r.name
        rcl_df: pd.DataFrame = self.load_df(rcl, [oid, rcl.unique_id, cf.esn_l, cf.esn_r])
        assert isinstance(rcl_df.spatial, GeoAccessor)
        esz_df: pd.DataFrame = self.load_df(esz, [esz.unique_id, cf.esn])
        assert isinstance(esz_df.spatial, GeoAccessor)
        errors: list[ValidationErrorMessage] = []


        temp_fc_name: Callable[[str], str] = partial(arcpy.CreateUniqueName, workspace="memory")
        temp_layer_name: Callable[[str], str] = lambda base: f"{base}_{self._enter_timestamp.timestamp():.0f}"
        """Given a base string, creates a unique name in ``memory``."""

        temp_items: list[str] = [
            esz_dissolve := temp_fc_name("esz_dissolve"),
            esz_lines := temp_fc_name("esz_lines"),
            rcl_layer := temp_layer_name("rcl_layer"),
        ]
        excluded_oids: set[int] = set()

        try:
            with arcpy.EnvManager(addOutputsToMap=False, overwriteOutput=True):
                # Should produce one POLYGON feature encompassing the area of all ESZ polygons
                arcpy.analysis.PairwiseDissolve(self.str_path_to(esz), out_feature_class=esz_dissolve)

                # # Produces LINE feature class with the perimeter of esz_dissolve
                # arcpy.management.FeatureToLine(esz_dissolve, esz_dissolve_perimeter, attributes="NO_ATTRIBUTES")
                # arcpy.conversion.ExportFeatures(esz_dissolve, "esz_dissolve")  # Debugging / TODO: Remove

                # Produces LINE feature class of the ESZ polygon boundaries
                arcpy.management.FeatureToLine(self.str_path_to(esz), esz_lines, attributes="NO_ATTRIBUTES")
                # arcpy.conversion.ExportFeatures(esz_lines, "esz_lines")  # Debugging / TODO: Remove

                # Layer of road centerline that will be used to make selections
                wc: str = f"{rcl.fields.submit:n} = 'Y'" if self.respect_submit else ""  # If SUBMIT is respected, only look at relevant centerlines for issues
                arcpy.management.MakeFeatureLayer(self.str_path_to(rcl), rcl_layer, where_clause=wc)


            # Find road centerline features that follow multiple ESZ boundaries or deviate from a boundary
            arcpy.management.SelectLayerByLocation(rcl_layer, "WITHIN", esz_dissolve, selection_type="NEW_SELECTION")  # Ignore features outside of ESZ polygons
            arcpy.management.SelectLayerByLocation(rcl_layer, "SHARE_A_LINE_SEGMENT_WITH", esz_lines, selection_type="SUBSET_SELECTION")
            arcpy.management.SelectLayerByLocation(rcl_layer, "WITHIN", esz_lines, selection_type="REMOVE_FROM_SELECTION")

            deviates_feature_count: int = int(arcpy.management.GetCount(rcl_layer).getOutput(0))
            if deviates_feature_count > 0:
                _logger.debug(f"{deviates_feature_count} road/ESZ boundary deviation(s) detected.")
                deviates_oids: set[int]
                deviates_nguids: set[str]
                with arcpy.da.SearchCursor(rcl_layer, ["OID@", nguid_rdcl]) as sc:
                    deviates_oids, deviates_nguids = map(set, zip(*sc))  # type: ignore
                excluded_oids |= deviates_oids
                errors += [FeatureAttributeErrorMessage.one_feature(
                    "Error", "ERROR:ROAD_ESN:DEVIATION", rcl.name, nguid, "Shape", None, "Feature deviates from an ESZ boundary and/or follows multiple ESZ boundaries."
                ) for nguid in deviates_nguids]
            else:
                _logger.debug("No road/ESZ boundary deviations detected.")


            # Find road centerline features that cross an ESZ boundary
            arcpy.management.SelectLayerByLocation(rcl_layer, "CROSSED_BY_THE_OUTLINE_OF", esz_lines, selection_type="NEW_SELECTION")

            crossing_feature_count: int = int(arcpy.management.GetCount(rcl_layer).getOutput(0))
            if crossing_feature_count > 0:
                _logger.debug(f"{crossing_feature_count} road/ESZ boundary crossing(s) detected.")
                crosses_oids: set[int]
                crosses_nguids: set[str]
                with arcpy.da.SearchCursor(rcl_layer, ["OID@", nguid_rdcl]) as sc:
                    crosses_oids, crosses_nguids = map(set, zip(*sc))  # type: ignore
                excluded_oids |= crosses_oids
                errors += [FeatureAttributeErrorMessage.one_feature(
                    "Error", "ERROR:ROAD_ESN:CROSSING", rcl.name, nguid, "Shape", None, "Feature crosses an ESZ boundary."
                ) for nguid in crosses_nguids]
            else:
                _logger.debug("No road/ESZ boundary crossings detected.")


            # Find road centerline features that aren't within any ESZ polygon or exceed the outer perimeter of all ESZ polygons
            arcpy.management.SelectLayerByLocation(rcl_layer, "WITHIN", esz_dissolve, selection_type="NEW_SELECTION", invert_spatial_relationship="INVERT")

            out_of_bounds_feature_count: int = int(arcpy.management.GetCount(rcl_layer).getOutput(0))
            if out_of_bounds_feature_count > 0:
                _logger.debug(f"{out_of_bounds_feature_count} roads out-of-bounds of ESZs detected.")
                oob_oids: set[int]
                oob_nguids: set[str]
                with arcpy.da.SearchCursor(rcl_layer, ["OID@", nguid_rdcl]) as sc:
                    oob_oids, oob_nguids = map(set, zip(*sc))  # type: ignore
                excluded_oids |= oob_oids
                errors += [FeatureAttributeErrorMessage.one_feature(
                    "Error", "ERROR:ROAD_ESN:OUT_OF_BOUNDS", rcl.name, nguid, "Shape", None, "Part or all of feature is outside of any ESZ."
                ) for nguid in oob_nguids]
            else:
                _logger.debug("No roads out-of-bounds of ESZs detected.")

            pass  # For some reason, putting this here keeps PyCharm from thinking that later code is unreachable

        finally:
            for item in temp_items:
                arcpy.management.Delete(item)

        #### Generate test points ever-so-slightly offset from each segment of each road feature ####

        rcl_df = rcl_df[~rcl_df[oid].isin(excluded_oids)]  # Remove excluded features for which errors have already been generated

        left_test_points: pd.DataFrame = rcl_df[rcl_df.spatial.name].apply(lambda line: [point_beside_line(line, "LEFT", 0.005, segment, 0, return_as=ag.Point, messenger=self.messenger) for segment in range(line.point_count - 1)]).explode().reset_index()
        right_test_points: pd.DataFrame = rcl_df[rcl_df.spatial.name].apply(lambda line: [point_beside_line(line, "RIGHT", 0.005, segment, 0, return_as=ag.Point, messenger=self.messenger) for segment in range(line.point_count - 1)]).explode().reset_index()

        assert isinstance(left_test_points.spatial, GeoAccessor)
        assert isinstance(right_test_points.spatial, GeoAccessor)

        left_test_points.rename(columns={rcl_df.spatial.name: "test_point"}, inplace=True)
        right_test_points.rename(columns={rcl_df.spatial.name: "test_point"}, inplace=True)
        # At this point, left_test_points and right_test_points are DataFrames with two columns, {nguid_rdcl} and "test_point"

        left_test_points.spatial.set_geometry("test_point", inplace=True)
        left_test_points["test_side"] = "LEFT"
        right_test_points.spatial.set_geometry("test_point", inplace=True)
        right_test_points["test_side"] = "RIGHT"

        test_points: pd.DataFrame = pd.concat([left_test_points, right_test_points], ignore_index=True)
        assert isinstance(test_points.spatial, GeoAccessor)
        del esz_df, left_test_points, right_test_points
        # At this point, test_points should have columns {nguid_rdcl}, "test_side", and "test_point"

        temp_items = [
            test_points_fc := temp_fc_name("test_points"),
            test_points_joined := temp_fc_name("test_points_esz"),
        ]

        try:
            test_points.spatial.to_featureclass(test_points_fc)
            arcpy.analysis.SpatialJoin(test_points_fc, self.str_path_to(esz), test_points_joined, "JOIN_ONE_TO_MANY", "KEEP_ALL", match_option="WITHIN")
            # Resulting feature class should have columns {nguid_rdcl}, "test_side", and all ESZ fields (with "test_point" as geometry)
            test_points: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(
                test_points_joined,
                fields=[nguid_rdcl, "test_side", nguid_esz, esn]
            )

        finally:
            for item in temp_items:
                arcpy.management.Delete(item)

        fill_l = cf.esn_l.fill_value
        fill_r = cf.esn_r.fill_value

        is_left: pd.Series = test_points["test_side"].eq("LEFT")
        """Boolean Series; whether {esn} on that row should correspond to {esn_l} on that row."""

        is_right: pd.Series = test_points["test_side"].eq("RIGHT")
        """Boolean Series; whether {esn} on that row should correspond to {esn_r} on that row."""

        # is_real_esn: pd.Series = test_points[esn].ne(cf.esn.fill_value)
        # """Boolean Series; whether {esn} on that row is non-zero"""

        test_points = test_points.join(rcl_df[[esn_l, esn_r]], how="left", on=nguid_rdcl, validate="m:1")
        test_points.loc[is_left, esn] = test_points.loc[is_left, esn].fillna(fill_l)  # Fill NA ESNs on left with {fill_l}
        test_points.loc[is_right, esn] = test_points.loc[is_right, esn].fillna(fill_r)  # Fill NA ESNs on right with {fill_r}

        # test_points.spatial.to_featureclass(str(self.gdb_path / "esn_test_points"))  # Export test points to help debugging
        # At this point, test_points has...
        # A sequential index
        # Geometry column from left_test_points/right_test_points
        # These columns from left_test_points/right_test_points:      {nguid_rdcl}, "test_side", {nguid_esz}, {esn}
        # These columns from rcl_df, joined based on {nguid_rdcl}:    {esn_l}, {esn_r}

        del rcl_df

        has_mismatch_left: pd.Series = is_left & test_points[esn].ne(test_points[esn_l]) & test_points[esn].ne(fill_l)
        """Boolean Series; True IF the ``test_points`` row represents the left
        AND the physical ESN does NOT match the {esn_l} attribute AND the left
        ESN is NOT set to the fill value (indicating that it IS set to a real
        value. True values necessitate errors."""

        outside_esz_left: pd.Series = is_left & test_points[esn].eq(fill_l) & test_points[esn_l].eq(fill_l)
        """Boolean Series; True IF the ``test_points`` row represents the left
        AND there is no known physical ESN on the left AND the left ESN is set
        to the fill value (indicating that is NOT set to a real value). True
        values necessitate notices."""

        try:
            assert has_mismatch_left.notna().all()
        except:
            has_mismatch_left.to_csv(self.gdb_path.parent / "has_mismatch_left.csv", index=True)
            raise
        assert outside_esz_left.notna().all()

        has_mismatch_right: pd.Series = is_right & test_points[esn].ne(test_points[esn_r]) & test_points[esn].ne(fill_r)
        """Boolean Series; True IF the ``test_points`` row represents the right
        AND the physical ESN does NOT match the {esn_r} attribute AND the right
        ESN is NOT set to the fill value (indicating that it IS set to a real
        value. True values necessitate errors."""

        outside_esz_right: pd.Series = is_right & test_points[esn].eq(fill_r) & test_points[esn_r].eq(fill_r)
        """Boolean Series; True IF the ``test_points`` row represents the right
        AND there is no known physical ESN on the right AND the right ESN is
        set to the fill value (indicating that is NOT set to a real
        value). True values necessitate notices."""

        assert has_mismatch_right.notna().all()
        assert outside_esz_right.notna().all()

        # has_mismatch_right.fillna(False, inplace=True)
        # error_df: pd.DataFrame = test_points[has_mismatch_left | has_mismatch_right]
        # notice_df: pd.DataFrame = test_points[outside_esz_left | outside_esz_right]
        # del test_points, has_mismatch_left, outside_esz_left, has_mismatch_right, outside_esz_right

        # cols_to_drop_duplicates = test_points.columns.difference(["SHAPE"])  # TODO: Remove
        # error_df_left_with_geometry: pd.DataFrame = test_points[has_mismatch_left]  # TODO: Remove
        # error_df_right_with_geometry: pd.DataFrame = test_points[has_mismatch_right]  # TODO: Remove
        # notice_df_left_with_geometry: pd.DataFrame = test_points[outside_esz_left]  # TODO: Remove
        # notice_df_right_with_geometry: pd.DataFrame = test_points[outside_esz_right]  # TODO: Remove
        error_df_left: pd.DataFrame = test_points[has_mismatch_left].drop(columns=["SHAPE"]).drop_duplicates()
        error_df_right: pd.DataFrame = test_points[has_mismatch_right].drop(columns=["SHAPE"]).drop_duplicates()
        notice_df_left: pd.DataFrame = test_points[outside_esz_left].drop(columns=["SHAPE"]).drop_duplicates()
        notice_df_right: pd.DataFrame = test_points[outside_esz_right].drop(columns=["SHAPE"]).drop_duplicates()
        # Each has columns {nguid_rdcl}, "test_side", {nguid_esz}, {esn}, and either {esn_l} or {esn_r}
        # del is_left, is_right, error_df, notice_df

        def _make_error_function(side: Literal["left", "right"]) -> Callable[[pd.Series], FeatureAttributeErrorMessage]:
            """Creates a side-specific function to produce ``FeatureAttributeErrorMessage`` instances given a ``Series`` object."""
            esn_x: str = {"left": esn_l, "right": esn_r}[side]  # Determine which road ESN field name to use
            def _make_error(row: pd.Series) -> FeatureAttributeErrorMessage:
                return FeatureAttributeErrorMessage(
                    "Error", "ERROR:CONSISTENCY:ROAD_ESN",
                    rcl.name, row[nguid_rdcl], esn_x, row[esn_x],
                    esz.name, row[nguid_esz], esn, row[esn],
                    f"Road Centerline feature's '{esn_x}' attribute does not match the '{esn}' attribute of the ESZ polygon on the road's {side} side."
                )
            return _make_error

        def _make_notice_function(side: Literal["left", "right"]) -> Callable[[pd.Series], FeatureAttributeErrorMessage]:
            """Creates a side-specific function to produce ``FeatureAttributeErrorMessage`` instances given a ``Series`` object."""
            esn_x: str = {"left": esn_l, "right": esn_r}[side]  # Determine which road ESN field name to use
            fill_value_text = val if isinstance(val := config.get_field_by_name(esn_x).fill_value, int | float) else f"'{val}'"
            def _make_notice(row: pd.Series) -> FeatureAttributeErrorMessage:
                return FeatureAttributeErrorMessage.one_feature(
                    "Notice", "NOTICE:CONSISTENCY:ROAD_ESN",
                    rcl.name, row[nguid_rdcl], esn_x, row[esn_x],
                    f"The {side} side of the road centerline lies outside of any ESZ polygon, but its '{esn_x}' attribute is set to {fill_value_text}. This may be ignored if intentional."
                )
            return _make_notice

        errors += error_df_left.apply(_make_error_function("left"), axis=1).to_list()
        errors += notice_df_left.apply(_make_notice_function("left"), axis=1).to_list()
        errors += error_df_right.apply(_make_error_function("right"), axis=1).to_list()
        errors += notice_df_right.apply(_make_notice_function("right"), axis=1).to_list()

        _logger.debug(f"Detected {len(error_df_left)} {esn_l} mismatch(es), {len(notice_df_left)} {esn_l} outsider(s), {len(error_df_right)} {esn_r} mismatch(es), {len(notice_df_right)} {esn_r} outsider(s)")

        return self._add_issues(errors)

    @staticmethod
    def _check_line_segments(line: AnyPolyline, min_degrees: float = 55., min_segment_length_meters: float = 5.) -> pd.Series:
        """Returned ``Series`` will have two ``float``\ s, the first
        representing whether a cutback was detected, and the second whether a
        short segment was detected."""
        """Returns ``True`` if *line* is found to have an angle sharper than
        *min_degrees* at any of its vertices, or ``False`` otherwise."""
        if isinstance(line, ag.Polyline):
            line: arcpy.Polyline = line.as_arcpy
        centroid: arcpy.Point = line.trueCentroid
        sr: arcpy.SpatialReference = line.spatialReference
        # noinspection PyUnresolvedReferences
        utm_sr: arcpy.SpatialReference = arcpy.GetUTMFromLocation(centroid.X, centroid.Y)
        line_utm: arcpy.Polyline = line.projectAs(utm_sr)
        has_curves: bool = line.hasCurves

        part_index: int
        part: arcpy.Array
        cutback_angle: float = np.nan
        short_segment_length: float = np.nan
        for part_index, part in enumerate(line):
            # print(f"==== PART #{part_index} ====")
            vertex_count: int = len(part)
            v_prev: arcpy.PointGeometry
            v_this: arcpy.PointGeometry
            v_next: arcpy.PointGeometry
            segment1: arcpy.Polyline = get_segment(line, part_index, 0, 1)
            seg1_length: float = segment1.getLength("GEODESIC", "METERS")
            short_segment_length = min(seg1_length, short_segment_length) if seg1_length < min_segment_length_meters else short_segment_length
            segment2: arcpy.Polyline
            for i in range(1, vertex_count - 1):
                segment2: arcpy.Polyline = get_segment(line, part_index, i, i + 1)
                seg2_length: float = segment2.getLength("GEODESIC", "METERS")
                # print(f"SEG2LENGTH: {seg2_length:.1f} | CURRENT SHORTEST: {short_segment_length:.1f} | MIN OF SEG2 & CURRENT: {min(seg2_length, short_segment_length):.1f}")
                short_segment_length = min(seg2_length, short_segment_length) if seg2_length < min_segment_length_meters else short_segment_length
                # print(f"SEG2 TOO SHORT? {seg2_length < min_segment_length_meters}")
                if seg2_length < min_segment_length_meters:
                    assert short_segment_length is not np.nan
                # print(f"Segment lengths: {seg1_length:.1f} / {seg2_length:.1f} | Worst Violation: {short_segment_length:.1f}")
                if has_curves:
                    # print(f"Vertices {i-1}-{i+1} [Curved]")
                    offset_length: float = min(seg1_length / 10, seg2_length / 10, 1.)  # 10 and 1 are rather arbitrary
                    v_this = get_vertex(line_utm, part_index, i)
                    v_this_position: float = line_utm.measureOnLine(v_this)
                    v_prev = line_utm.positionAlongLine(v_this_position - offset_length)
                    v_next = line_utm.positionAlongLine(v_this_position + offset_length)
                    # print(f"Offset length: {offset_length:.3f} | Position: {v_this_position:.3f}")
                else:
                    v_prev, v_this, v_next = (arcpy.PointGeometry(vertex, sr) for vertex in part[i - 1:i + 2])
                    # print(f"Vertices {i-1}-{i+1} [Not Curved]")
                angle1: float = v_this.angleAndDistanceTo(v_prev)[0]
                angle2: float = v_this.angleAndDistanceTo(v_next)[0]
                vertex_angle: float = abs(angle2 - angle1)
                if vertex_angle < min_degrees:
                    cutback_angle = min(vertex_angle, cutback_angle)
                # print(f"Angles: {angle1:.1f} / {angle2:.1f} | Vertex: {vertex_angle} | Worst Violation: {cutback_angle:.1f}")
                segment1 = segment2
                seg1_length = seg2_length
            # print(f"Part #{part_index} Worst Violations - Angle: {cutback_angle} | Length: {short_segment_length}")

        return pd.Series({"$cutback_degrees": cutback_angle, "$short_segment_meters": short_segment_length})

    def check_road_geometry(self) -> list[ValidationErrorMessage]:
        """Checks road centerline segments for sharp angles (cutbacks) and
        exceptionally short segments."""
        rcl = config.feature_classes.road_centerline
        # rcl_sp = r"memory\rcl_sp"  # [R]oad [C]enter[l]ine [S]ingle [P]art
        self._precheck(self.check_feature_class_exists(rcl))

        # with self.required_ds_env_manager:
        #     arcpy.MultipartToSinglepart_management(rcl.name, rcl_sp)

        errors: list[ValidationErrorMessage] = []
        rcl_df: pd.DataFrame = self.load_df(rcl, [rcl.unique_id])
        rcl_df[["$cutback_degrees", "$short_segment_meters"]] = rcl_df["SHAPE"].apply(self._check_line_segments)

        cutback_validity = rcl_df.applymap(lambda _: True)
        cutback_validity["$cutback_degrees"] = cutback_validity["$cutback_degrees"].isna()
        errors += FeatureAttributeErrorMessage.from_df(rcl_df, cutback_validity, "Warning", "WARNING:GEOMETRY:CUTBACK", rcl.name, lambda info: f"Feature geometry contains a vertex with angle ~{round(info.value1)}°")

        length_validity = rcl_df.applymap(lambda _: True)
        length_validity["$short_segment_meters"] = length_validity["$short_segment_meters"].isna()
        errors += FeatureAttributeErrorMessage.from_df(rcl_df, cutback_validity, "Warning", "WARNING:GEOMETRY:SHORT_SEGMENT", rcl.name,
                                                       lambda info: f"Feature geometry contains a segment with length of only ~{round(info.value1, 2)} meters.")

        return self._add_issues(errors)

    def check_road_level(self) -> list[ValidationErrorMessage]:
        """Checks road centerline features' from- and to-level attributes."""
        # TODO: Future enhancement - involve LtdAccess attribute?

        rcl = config.feature_classes.road_centerline
        rclf = rcl.fields
        self._precheck(self.check_feature_class_exists(rcl))
        self._precheck(self.check_fields_exist(rcl, (rclf.fromlevel, rclf.tolevel)))
        df_fields = [rcl.unique_id, rclf.fullname, rclf.fromlevel, rclf.tolevel, rclf.add_l_from, rclf.add_r_from]
        nguid, fullname, fromlevel, tolevel, add_l_from, add_r_from = [f.name for f in df_fields]

        road_df: pd.DataFrame = self.load_df(rcl, df_fields)
        assert isinstance(road_df.spatial, GeoAccessor)
        SHAPE: str = road_df.spatial.name

        # Create a column called "$start_geom" to hold the first vertex (i.e. start point) of each feature
        road_df.loc[:, "$start_geom"] = road_df[SHAPE].apply(lambda g: ag.Point(get_vertex(g, 0))).astype("geometry")

        # Create a column called "$end_geom" to hold the last vertex (i.e. end point) of each feature)
        road_df.loc[:, "$end_geom"] = road_df[SHAPE].apply(lambda g: ag.Point(get_vertex(g, -1))).astype("geometry")

        # # Combine the start/end point columns into a single ``Series``, drop duplicate points, then convert to a ``DataFrame``
        # road_points: pd.DataFrame = pd.concat([road_df["$start_geom"], road_df["$end_geom"]], ignore_index=True).drop_duplicates().to_frame("$point_geom").reset_index(drop=True)

        # Subset road_df to get each feature's start level/point and end level/point, normalizing the names of the columns so they can be concatenated
        start_df: pd.DataFrame = road_df[[nguid, fromlevel, "$start_geom"]].rename(columns={fromlevel: "$level", "$start_geom": "$point_geom"})
        start_df["$level_field"] = fromlevel
        end_df: pd.DataFrame = road_df[[nguid, tolevel, "$end_geom"]].rename(columns={tolevel: "$level", "$end_geom": "$point_geom"})
        end_df["$level_field"] = tolevel
        elevation_df: pd.DataFrame = pd.concat([start_df, end_df]).set_index([nguid, "$level_field"])
        """
        A ``pandas.DataFrame`` that, at this point, looks something like this::

                                              $level          $point_geom
            NGUID_RDCL           $level_field
            ROAD_CENTERLINE_1... ToLevel           0  {"x": -98.990321...
            ROAD_CENTERLINE_9... FromLevel         0  {"x": -98.954449...
            ROAD_CENTERLINE_6... ToLevel           0  {"x": -98.950576...
            ROAD_CENTERLINE_2... FromLevel         0  {"x": -99.036201...
        """

        levels_are_consistent: pd.Series = elevation_df.groupby("$point_geom", sort=False)["$level"].apply(lambda s: s.notna().all() and s.nunique() == 1)
        """``Series`` of ``bool`` where ``$point_geom`` is the index; the values indicate whether all values of ``$level`` were equal (and non-null) for each ``$point_geom``."""
        levels_are_consistent.name = "$point_level_consistent"

        elevation_df = elevation_df.join(levels_are_consistent, on="$point_geom")
        errors: list[ValidationErrorMessage] = elevation_df.apply(lambda row: FeatureAttributeErrorMessage.one_feature(
            "Warning", "WARNING:CONSISTENCY:ROAD_LEVEL",
            rcl.name, row.name[0], row.name[1], row["$level"],
            f"All road features with a common endpoint must have consistent {fromlevel}/{tolevel} attributes for that point (unless they are grade-separated)."
        ), axis=1).to_list()  # Because of multi-index, row.name[0] => NGUID and row.name[1] => $level_field
        return self._add_issues(errors)

    # @staticmethod
    # def _check_address_range_combo(range1: AddressRange, range2: AddressRange) -> list[FeatureAttributeErrorMessage]:

    @staticmethod
    def _check_single_road_address_ranges(group: pd.DataFrame) -> list[FeatureAttributeErrorMessage]:
        # def _check_single_road_address_ranges(key: tuple[str, str], group: pd.DataFrame) -> list[FeatureAttributeErrorMessage]:
        # group_fullname, group_msagcomm = key
        errors: list[FeatureAttributeErrorMessage] = []

        rcl = config.feature_classes.road_centerline
        # from_fields = {"LEFT": rcl.fields.add_l_from, "RIGHT": rcl.fields.add_r_from}
        # to_fields = {"LEFT": rcl.fields.add_l_to, "RIGHT": rcl.fields.add_r_to}
        row1: tuple[int, GPParameterValue, GPParameterValue, GPParameterValue, AddressRange, Literal["LEFT", "RIGHT"]]
        row2: tuple[int, GPParameterValue, GPParameterValue, GPParameterValue, AddressRange, Literal["LEFT", "RIGHT"]]
        for row1, row2 in combinations(group.itertuples(), 2):
            nguid1, fullname1, msagcomm1, range1, side1 = row1[1:]
            nguid2, fullname2, msagcomm2, range2, side2 = row2[1:]
            if overlap := range1 & range2:
                errors.append(
                    FeatureAttributeErrorMessage("Error", "ERROR:ADDRESS_RANGE:OVERLAP", rcl.name, nguid1, f"$address_range [{side1}]", str(range1), rcl.name, nguid2, f"$address_range [{side2}]", str(range2),
                                                 f"Address ranges overlap. Overlapping range: {overlap.details}")
                )

        return errors

        # [*filter(bool, itertools.starmap(AddressRange.__and__, itertools.combinations([r1, r2, r3, r4, r5, r6], 2)))]

    def check_address_range_overlaps(self) -> list[ValidationErrorMessage]:
        """Detects overlapping address ranges in the road centerline feature
        class."""
        # self._precheck(self.check_address_range_directionality())
        errors: list[ValidationErrorMessage] = []

        rcl = config.feature_classes.road_centerline
        rclf = rcl.fields
        df_fields: list[NG911Field] = [rcl.unique_id, rclf.fullname, rclf.msagcomm_l, rclf.msagcomm_r, rclf.add_l_from, rclf.add_l_to, rclf.add_r_from, rclf.add_r_to, rclf.parity_l, rclf.parity_r]
        nguid, fullname, msagcomm_l, msagcomm_r, add_l_from, add_l_to, add_r_from, add_r_to, parity_l, parity_r = [f.name for f in df_fields]
        road_df: pd.DataFrame = self.load_df(rcl, df_fields)
        left_df: pd.DataFrame = road_df[[nguid, fullname, msagcomm_l]].rename(columns={msagcomm_l: "$msagcomm"})
        left_df["$address_range"] = road_df.ng911.address_range_left(suppress_errors=True, auto_fix_directionality=True)
        left_df["$side"] = "LEFT"
        right_df: pd.DataFrame = road_df[[nguid, fullname, msagcomm_r]].rename(columns={msagcomm_r: "$msagcomm"})
        right_df["$address_range"] = road_df.ng911.address_range_right(suppress_errors=True, auto_fix_directionality=True)
        right_df["$side"] = "RIGHT"

        range_df: pd.DataFrame = pd.concat([left_df, right_df]).reset_index(drop=True)  # Columns: nguid, fullname, "$msagcomm", "$address_range", "$side"
        valid_ranges: pd.Series = range_df["$address_range"].apply(lambda ar: ar.is_valid)
        errors += [*chain(*(  # itertools.chain() concatenates the returned lists
            range_df[valid_ranges].groupby([fullname, "$msagcomm"]).apply(self._check_single_road_address_ranges)
        ))]

        return self._add_issues(errors)

    def check_address_range_directionality(self) -> list[ValidationErrorMessage]:
        """Detects road centerline features with from-address attributes that
        are higher than their corresponding to-address attributes. Accounts for
        the circular addressing exception described in Issue #10."""
        errors: list[ValidationErrorMessage] = []

        rcl = config.feature_classes.road_centerline
        rclf = rcl.fields
        df_fields: list[NG911Field] = [rcl.unique_id, rclf.add_l_from, rclf.add_l_to, rclf.add_r_from, rclf.add_r_to, rclf.parity_l, rclf.parity_r]
        nguid, add_l_from, add_l_to, add_r_from, add_r_to, parity_l, parity_r = [f.name for f in df_fields]
        road_df: pd.DataFrame = self.load_df(rcl, df_fields)

        validity_left: pd.Series = road_df[add_l_from].le(road_df[add_l_to])
        validity_right: pd.Series = road_df[add_r_from].le(road_df[add_r_to])

        # Cirucular addressing exception
        dir_l: pd.Series = road_df.ng911.directionality_left
        dir_r: pd.Series = road_df.ng911.directionality_right
        parities: pd.DataFrame = pd.merge(road_df.ng911.parity_left, road_df.ng911.parity_right, left_index=True, right_index=True)
        parities_both_on_both_sides: pd.Series = parities.eq(Parity.BOTH).all("columns")
        exception_left: pd.Series = dir_l.eq(Directionality.DECREASING) & dir_r.eq(Directionality.INCREASING) & parities_both_on_both_sides
        exception_right: pd.Series = dir_l.eq(Directionality.INCREASING) & dir_r.eq(Directionality.DECREASING) & parities_both_on_both_sides
        validity_left |= exception_left
        validity_right |= exception_right

        errors += FeatureAttributeErrorMessage.from_df_two_fields(
            road_df, validity_left, add_l_from, add_l_to,
            "Error", "ERROR:ADDRESS_RANGE:DECREASING", rcl.name,
            lambda info: f"Left address range goes from {info.value1} to {info.value2}. Except in specific cases, address ranges should never go from a higher number to a lower number.")
        errors += FeatureAttributeErrorMessage.from_df_two_fields(
            road_df, validity_right, add_r_from, add_r_to,
            "Error", "ERROR:ADDRESS_RANGE:DECREASING", rcl.name,
            lambda info: f"Right address range goes from {info.value1} to {info.value2}. Except in specific cases, address ranges should never go from a higher number to a lower number.")

        return self._add_issues(errors)

    @staticmethod
    def _has_dangles(df: pd.DataFrame, honor_exceptions: bool = True) -> pd.Series:
        raise NotImplementedError
        geometry: pd.Series = df[df.spatial.name]
        nguid: str = df.index.name
        if geometry.geom.part_count.ne(1).any():
            raise ValueError("Input geometry must be single-part.")
        start_points: pd.DataFrame = geometry.geom.first_point.to_frame("$point")
        start_points["$position"] = "START"
        start_points.reset_index().set_index([nguid, "$position"])
        end_points: pd.DataFrame = geometry.geom.last_point.to_frame("$point")
        end_points["$position"] = "END"
        end_points.reset_index().set_index([nguid, "$position"])
        points: pd.DataFrame = pd.concat([start_points, end_points])
        # TODO 1/6: FINISH

    def check_feature_locations(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        raise NotImplementedError
        one_member_rule_map: dict[str, Callable[[pd.DataFrame], pd.Series]] = {
            "Must Be Single Part (Line)": lambda df: df[df.spatial.name].geom.part_count.eq(1),
            "Must Not Have Dangles (Line)": self._has_dangles,
            "Must Not Have Gaps (Area)": ...,
            "Must Not Overlap (Area)": ...,
            "Must Not Overlap (Line)": ...,
            "Must Not Self-Intersect (Line)": ...,
            "Must Not Self-Overlap (Line)": ...
        }
        two_member_rule_map: dict[str, Callable[[pd.DataFrame, pd.DataFrame], pd.Series]] = {
            "Must Be Inside (Line-Area)": ...,
            "Must Be Properly Inside (Point-Area)": ...,
            "Must Cover Each Other (Area-Area)": ...
        }
        # TODO 1/6: FINISH

    def check_uniqueness(self, feature_class: NG911FeatureClass) -> list[ValidationErrorMessage]:
        nguid: str = feature_class.unique_id.name
        if feature_class == config.feature_classes.address_point:
            f = config.feature_classes.address_point.fields
            fields_to_check = [f.addnumpre, f.addnumber, f.addnumsuf, *config.street_fields, f.bldgname, f.floor, f.bldgunit, f.room, f.seat, f.addtnlloc, f.city, f.county, f.state, f.country, f.msagcomm]
            where_clause = None
        elif feature_class == config.feature_classes.road_centerline:
            f = config.feature_classes.road_centerline.fields
            fields_to_check = [*config.street_fields, f.state_l, f.state_r, f.county_l, f.county_r, f.city_l, f.city_r, f.country_l, f.country_r, f.add_l_from, f.add_l_to, f.add_r_from, f.add_r_to, f.parity_l, f.parity_r, f.postcomm_l, f.postcomm_r, f.zipcode_l, f.zipcode_r, f.esn_l, f.esn_r, f.msagcomm_l, f.msagcomm_r, f.speedlimit, f.oneway, f.roadclass, f.fromlevel, f.tolevel, f.surface, f.boundlane]  # TODO: Include addpre fields?
            # Where clause to exclude any features with ZERO parity and 0-0 address ranges to prevent errors on roads without addressing (e.g. freeways)
            where_clause = f"NOT ({f.add_l_from:n} = 0 AND {f.add_l_to:n} = 0 AND {f.parity_l:n} = '{Parity.ZERO}' AND {f.add_r_from:n} = 0 AND {f.add_r_to:n} = 0 AND {f.parity_r:n} = '{Parity.ZERO}')"
        else:
            raise ValueError("check_uniqueness() only accepts an address point or a road centerline feature class object as the feature_class argument.")

        field_names_str = ", ".join([f"'{f:n}'" for f in fields_to_check])
        field_names_to_check = [f.name for f in fields_to_check]

        df = self.load_df(feature_class, [feature_class.unique_id, *fields_to_check], where_clause=where_clause)
        error_nguids: list[str] = []
        for combo, group in df.groupby(field_names_to_check, dropna=False):
            if len(group) > 1:
                error_nguids += [*group.index]

        error_rows: pd.DataFrame = df.loc[error_nguids, :].copy()
        error_rows["$uniqueness_concat"] = error_rows[field_names_to_check].apply(
            lambda row: "|".join(row.astype("string").fillna("<Null>")),
            axis=1
        )
        # _logger.debug(f"Columns: {error_rows.columns}")
        error_rows = error_rows[[nguid, "$uniqueness_concat"]].copy()
        validity = error_rows.applymap(lambda _: True)
        validity["$uniqueness_concat"] = False
        if feature_class == config.feature_classes.road_centerline:
            # Features with ZERO parity on either side omitted, otherwise some roads (e.g. freeways) would erroneously be flagged
            # This could miss errors where only one side is ZERO, but those should be flagged by the address overlap check anyway
            validity.loc[df[[f.parity_l.name, f.parity_r.name]].eq(Parity.ZERO.value).any(axis=1), "$uniqueness_concat"] = True
        errors: list[ValidationErrorMessage] = FeatureAttributeErrorMessage.from_df(error_rows, validity, "Error", "ERROR:GENERAL:UNIQUENESS", feature_class.name, f"This combination of attributes ({field_names_str}) should be unique for every feature in the feature class, but was found multiple times.")

        return self._add_issues(errors)

    @staticmethod
    def _check_msagcomm_consistency(df: pd.DataFrame, msagcomm_field: NG911Field, city_field: NG911Field, county_field: NG911Field) -> list[ValidationErrorMessage]:
        nguid_field: NG911Field = df.attrs["feature_class"].unique_id
        layer_name: str = df.attrs["feature_class"].name
        city_fill_value: str = city_field.fill_value
        nguid, msagcomm, city, county = [f.name for f in [nguid_field, msagcomm_field, city_field, county_field]]
        subset_df: pd.DataFrame = df[[nguid, msagcomm, city, county]].copy()

        city_is_valid = lambda city_value: city_value not in {city_field.fill_value, "", " "} and pd.notna(city_value)
        subset_df["$expected_msagcomm"] = df.apply(lambda row: row[city] if city_is_valid(row[city]) else row[county], axis=1)
        validity: pd.Series = subset_df[msagcomm].eq(subset_df["$expected_msagcomm"])
        return FeatureAttributeErrorMessage.from_df_two_fields(
            subset_df, validity, msagcomm, "$expected_msagcomm", "Error", "ERROR:CONSISTENCY:COMMUNITY", layer_name, f"Expected {msagcomm} to match {city} if {city} is neither blank nor '{city_fill_value}', or {county} otherwise."
        )

    def check_address_msagcomm_consistency(self) -> list[ValidationErrorMessage]:
        ap = config.feature_classes.address_point
        apf = ap.fields
        field_args: list[NG911Field] = [apf.msagcomm, apf.city, apf.county]
        df = self.load_df(ap, [ap.unique_id, *field_args])
        errors: list[ValidationErrorMessage] = self._check_msagcomm_consistency(df, *field_args)
        return self._add_issues(errors)

    def check_road_msagcomm_consistency(self) -> list[ValidationErrorMessage]:
        rcl = config.feature_classes.road_centerline
        rclf = rcl.fields
        left_field_args: list[NG911Field] = [rclf.msagcomm_l, rclf.city_l, rclf.county_l]
        right_field_args: list[NG911Field] = [rclf.msagcomm_r, rclf.city_r, rclf.county_r]
        df = self.load_df(rcl, [rcl.unique_id, *left_field_args, *right_field_args])
        errors: list[ValidationErrorMessage] = self._check_msagcomm_consistency(df, *left_field_args) + self._check_msagcomm_consistency(df, *right_field_args)
        return self._add_issues(errors)

        # class FullNameCalculator:
        #     def __init__(self, std_gdb_path, fc_name_list, variant_list):
        #         self.required_dataset_name = config.gdb_info.required_dataset_name
        #         self.std_gdb_path = std_gdb_path
        #         self.fc_name_list = fc_name_list
        #         self.variant_list = variant_list
        #         self.target_field = None
        #         self.street_fields = config.street_field_names
        #
        #     def execute_calculation(self):
        #         arcpy.AddMessage(f"Current analysis parameters:")
        #         run_check = 1
        #         for variant in self.variant_list:
        #             self.target_field = config.fields.fullname.name
        #             field_list = self.street_fields
        #             if variant.lower() == "legacy":
        #                 self.target_field = config.fields.lgcyfulst.name
        #                 field_list[field_list.index(config.fields.predir.name)] = config.fields.lgcypredir.name
        #                 field_list[field_list.index(config.fields.pretype.name)] = config.fields.lgcypretyp.name
        #                 field_list[field_list.index(config.fields.street.name)] = config.fields.lgcystreet.name
        #                 field_list[field_list.index(config.fields.streettype.name)] = config.fields.lgcytype.name
        #                 field_list[field_list.index(config.fields.sufdir.name)] = config.fields.lgcysufdir.name
        #             for fc_name in self.fc_name_list:
        #                 if fc_name == config.feature_classes.address_point.name:  # ADDRESS
        #                     fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
        #                 else:  # ROAD
        #                     fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
        #                 arcpy.AddMessage(f"...Run {run_check}\tVariant: {variant}\n\tFC: {fc_name}")
        #                 edit = arcpy.da.Editor(self.std_gdb_path)
        #                 edit.startEditing(False, False)
        #                 u_cursor = arcpy.da.UpdateCursor(fc_path, [self.target_field] + field_list)
        #                 for row in u_cursor:
        #                     expr_string = ' '.join([f'{field_val}' for field_val in row[1:] if field_val not in ['', ' ', 'None', 'Null', None]])
        #                     if expr_string in ['', ' ', 'None', 'Null', None]:
        #                         expr_string = None
        #                     row[0] = expr_string
        #                     u_cursor.updateRow(row)
        #                 edit.stopEditing(True)
        #                 run_check += 1
        #
        # class FullAddrCalculator:
        #     def __init__(self, std_gdb_path, variant_list):
        #         self.std_gdb_path = std_gdb_path
        #         self.required_dataset_name = config.gdb_info.required_dataset_name
        #         self.fc_name = config.feature_classes.address_point.name
        #         # self.fc_name_list = fc_name_list
        #         self.variant_list = variant_list
        #         self.target_field = None
        #         self.street_fields = config.street_field_names
        #         self.fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, self.fc_name)
        #
        #     def execute_calculation(self):
        #         for variant in self.variant_list:
        #             self.target_field = config.fields.fulladdr.name
        #             street_for_analysis = self.street_fields
        #             if variant.lower() == "legacy":
        #                 self.target_field = config.fields.lgcyfuladd.name
        #                 street_for_analysis[street_for_analysis.index(config.fields.predir.name)] = config.fields.lgcypredir.name
        #                 street_for_analysis[street_for_analysis.index(config.fields.pretype.name)] = config.fields.lgcypretyp.name
        #                 street_for_analysis[street_for_analysis.index(config.fields.street.name)] = config.fields.lgcystreet.name
        #                 street_for_analysis[street_for_analysis.index(config.fields.streettype.name)] = config.fields.lgcytype.name
        #                 street_for_analysis[street_for_analysis.index(config.fields.sufdir.name)] = config.fields.lgcysufdir.name
        #             field_list = [
        #                 config.fields.addnumpre.name,
        #                 config.fields.addnumber.name,
        #                 config.fields.addnumsuf.name,
        #                 *street_for_analysis
        #             ]
        #             edit = arcpy.da.Editor(self.std_gdb_path)
        #             edit.startEditing(False, False)
        #             u_cursor = arcpy.da.UpdateCursor(self.fc_path, [self.target_field] + field_list)
        #             for row in u_cursor:
        #                 expr_string = ' '.join([f'{field_val}' for field_val in row[1:] if field_val not in ['', ' ', 'None', 'Null', None]])
        #                 if expr_string in ['', ' ', 'None', 'Null', None]:
        #                     expr_string = None
        #                 row[0] = expr_string
        #                 u_cursor.updateRow(row)
        #             edit.stopEditing(True)

        # road_fc_path = parameters[std_road_fc_idx].valueAsText
        # field_table = parameters[field_table_idx].values[0]
        # field_dict = {}
        # missing_field_list = []
        # for key, field in enumerate(tool_field_list):
        #     user_field = field_table[key]
        #     if not user_field or user_field in error_list:
        #         missing_field_list.append(field)
        #         continue
        #     field_dict[field] = user_field
        # if missing_field_list:
        #     arcpy.AddError(f"Road Field(s) not provided; please specify: {', '.join(missing_field_list)}.")
        #     raise Exception(f"Road Field(s) not provided; please specify: {', '.join(missing_field_list)}.")
        # unique_id = field_dict[unique_id_std_field_name]
        # fullname = field_dict[fullname_std_field_name]
        # fromlevel = field_dict[fromlevel_std_field_name]
        # tolevel = field_dict[tolevel_std_field_name]
        # add_l_from = field_dict[add_l_from_std_field_name]
        # add_r_from = field_dict[add_r_from_std_field_name]
        #
        # arcpy.AddMessage(f"{random_start_phrase}")
        # arcpy.AddMessage(f"\nBeginning Road Elevation Analysis...")
        # road_df = pd.DataFrame.spatial.from_featureclass(road_fc_path, sql_clause=(None, f"ORDER BY {fullname}, {add_l_from}, {add_r_from}"))
        #
        # bad_segments = []
        # idx_counter = 0
        # while idx_counter < (len(road_df.index.to_list()) - 1):
        #     current_idx = idx_counter
        #     next_row_idx = current_idx + 1
        #     current_row = road_df.iloc[current_idx]
        #     next_row = road_df.iloc[next_row_idx]
        #     # Bad segment notices: Same road name, segments not disjointed, and current segment `ToLevel` != next segment `FromLevel`
        #     if current_row[f"{fullname}"] == next_row[f"{fullname}"] and not current_row.SHAPE.disjoint(next_row.SHAPE) and current_row[f"{tolevel}"] != next_row[f"{fromlevel}"]:
        #         bad_segments.append([current_row[f"{unique_id}"], current_row[f"{tolevel}"], next_row[f"{unique_id}"], next_row[f"{fromlevel}"]])
        #     idx_counter += 1
        #
        # arcpy.AddMessage(f"\tTEXT.")
        #
        # return

    # VALIDATION FUNCTION TEMPLATE
    # def _(self) -> list[ValidationErrorMessage]:
    #     issues: list[ValidationErrorMessage] = []
    #
    #     return self._add_issues(issues)

    # # ROUTINE: Check Feature Class List
    # def check_feature_class_list(self) -> list[ValidationErrorMessage]:
    #     issues: list[ValidationErrorMessage] = []
    #     issues += self.check_feature_dataset_exists(config.gdb_info.required_dataset_name)
    #     for required_fc_name in config.required_feature_class_names:
    #         issues += self.check_feature_class_exists(feature_class_name=required_fc_name)
    #     return issues

    #### GEODATABASE ROUTINES ####

    @routine("Check Feature Class List", "Geodatabase")
    def check_feature_class_list_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Feature Class List*"""
        errors: list[ValidationErrorMessage] = []
        errors += self.check_feature_dataset_exists(config.gdb_info.required_dataset_name)
        for required_fc_name in config.required_feature_class_names:
            fc = config.get_feature_class_by_name(required_fc_name)
            errors += self.check_feature_class_exists(fc)
        errors += self.check_geodatabase_for_extra_items()
        return errors

    @routine("Check Feature Class Configuration", "Geodatabase")
    def check_feature_class_configuration_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Required Fields*"""
        errors: list[ValidationErrorMessage] = []
        for required_fc_name in config.required_feature_class_names:
            fc = config.get_feature_class_by_name(required_fc_name)
            errors += self.check_feature_class_configuration(fc)
        return errors

    @routine("Check Geodatabase Domains", "Geodatabase")
    def check_geodatabase_domains_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Geodatabase Domains*"""
        errors: list[ValidationErrorMessage] = self.check_gdb_domains()
        return errors

    @routine("Check Spatial Reference", "Geodatabase")
    def check_spatial_reference_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Spatial Reference*"""
        errors: list[ValidationErrorMessage] = self.check_dataset_spatial_reference(config.gdb_info.required_dataset_name)
        if config.gdb_info.optional_dataset_name in arcpy.ListDatasets():
            errors += self.check_dataset_spatial_reference(config.gdb_info.optional_dataset_name)
        return errors

    @routine("Check Topology", "Geodatabase")
    def check_topology_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Topology*"""
        self._precheck(self.check_feature_class_list_routine())
        # _logger.debug("Passed check_feature_class_list_routine() precheck.")
        for required_fc_name in config.required_feature_class_names:
            self._precheck(self.check_attributes_against_domains_routine(required_fc_name))
        # self._precheck([*chain([self.check_attributes_against_domains_routine(x) for x in config.required_feature_class_names])])
        # _logger.debug("Passed check_attributes_against_domains_routine() precheck.")
        errors: list[ValidationErrorMessage] = self.check_topology()
        return errors

    #### GENERAL FEATURE CLASS ROUTINES ####

    @routine("Check Unique ID Format", "General Feature Class", True)
    def check_unique_id_format_routine(self, feature_class_name: str) -> list[ValidationErrorMessage]:
        """Validation routine *Check Unique ID Format*"""
        fc_obj: NG911FeatureClass = config.get_feature_class_by_name(feature_class_name)
        self._precheck(self.check_feature_class_exists(fc_obj))
        self._precheck(self.check_unique_id_frequency(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_unique_id_format(fc_obj)
        return errors

    @routine("Check Unique ID Frequency", "General Feature Class", True)
    def check_unique_id_frequency_routine(self, feature_class_name: str) -> list[ValidationErrorMessage]:
        """Validation routine *Check Unique ID Frequency*"""
        fc_obj: NG911FeatureClass = config.get_feature_class_by_name(feature_class_name)
        self._precheck(self.check_feature_class_exists(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_unique_id_frequency(fc_obj)
        return errors

    @routine("Check Required Field Attributes", "General Feature Class", True)
    def check_required_field_attributes_routine(self, feature_class_name: str) -> list[ValidationErrorMessage]:
        """Validation routine *Check Required Field Attributes*"""
        fc_obj: NG911FeatureClass = config.get_feature_class_by_name(feature_class_name)
        self._precheck(self.check_feature_class_exists(fc_obj))
        self._precheck(self.check_unique_id_frequency(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_attributes(fc_obj)
        return errors

    @routine("Check Attributes Against Domains", "General Feature Class", True)
    def check_attributes_against_domains_routine(self, feature_class_name: str) -> list[ValidationErrorMessage]:
        """Validation routine *Check Attributes Against Domains*"""
        fc_obj: NG911FeatureClass = config.get_feature_class_by_name(feature_class_name)
        self._precheck(self.check_feature_class_exists(fc_obj))
        self._precheck(self.check_unique_id_frequency(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_fields_against_domains(fc_obj)
        return errors

    @routine("Check Submission Counts", "General Feature Class", True)
    def check_submission_counts_routine(self, feature_class_name: str) -> list[ValidationErrorMessage]:
        """Validation routine *Check Submission Counts*"""
        fc_obj: NG911FeatureClass = config.get_feature_class_by_name(feature_class_name)
        self._precheck(self.check_feature_class_exists(fc_obj))
        self._precheck(self.check_unique_id_frequency(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_submission_counts(fc_obj)
        return errors

    # @routine("Check Feature Locations", "General Feature Class", True)
    # def check_feature_locations_routine(self, feature_class_name: str) -> list[ValidationErrorMessage]:
    #     """Validation routine *Check Feature Locations*"""
    #     # TODO: Implement
    #     self.messenger.addMessage(f"Routine 'Check Feature Locations' has not been implemented yet. This will not prevent submission.")
    #     return []
    #     # fc_obj: NG911FeatureClass = config.get_feature_class_by_name(feature_class_name)
    #     # self._precheck(self.check_feature_class_exists(fc_obj))
    #     # errors: list[ValidationErrorMessage] = self.check_feature_locations(fc_obj)
    #     # return errors

    @routine("Check Next-Gen Against Legacy Fields", "General Feature Class", False)
    def check_next_gen_against_legacy_fields_routine(self) -> list[ValidationErrorMessage]:
        """
        Validation routine *Check Next-Gen Against Legacy Fields*.

        Calls :meth:`check_next_gen_against_legacy`, which, in turn, calls as
        prerequisites:

        - :meth:`check_feature_class_exists`,
        - :meth:`check_unique_id_frequency`
        - :meth:`check_fields_against_domains`
        """
        errors: list[ValidationErrorMessage] = self.check_next_gen_against_legacy()
        return errors

    #### ADDRESS POINT ROUTINES ####

    @routine("Check ESN Attribute (Address Point)", "Address Point", required_feature_classes={"address_point", "esz_boundary"})
    def check_esn_attribute_address_point_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check ESN and City Attributes (Address Point)*"""
        self._precheck(self.check_unique_id_frequency(config.feature_classes.address_point))
        self._precheck(self.check_attributes(config.feature_classes.address_point))
        errors: list[ValidationErrorMessage] = self.check_address_point_esn()
        return errors

    @routine("Check Uniqueness (Address Point)", "Address Point", required_feature_classes={"address_point"})
    def check_uniqueness_address_point_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Uniqueness*"""
        fc_obj: NG911FeatureClass = config.feature_classes.address_point
        self._precheck(self.check_feature_class_exists(fc_obj))
        self._precheck(self.check_unique_id_frequency(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_uniqueness(fc_obj)
        return errors

    @routine("Check RCLMatch", "Address Point", required_feature_classes={"address_point", "road_centerline"})
    def check_rclmatch_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check RCLMatch*"""
        self._precheck(self.check_unique_id_frequency(config.feature_classes.address_point))
        self._precheck(self.check_unique_id_frequency(config.feature_classes.road_centerline))
        errors: list[ValidationErrorMessage] = self.check_addresses_against_roads()
        return errors

    # @routine("Check MSAGComm Consistency (Address Point)", "Address Point", required_feature_classes={"address_point"})
    # def check_msagcomm_consistency_address_point_routine(self) -> list[ValidationErrorMessage]:
    #     """Validation routine *Check MSAGComm Consistency (Address Point)*"""
    #     errors: list[ValidationErrorMessage] = self.check_address_msagcomm_consistency()
    #     return errors

    #### ROAD CENTERLINE ROUTINES ####

    @routine("Check ESN Attribute (Road Centerline)", "Road Centerline", required_feature_classes={"road_centerline", "esz_boundary"})
    def check_esn_attribute_road_centerline_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check ESN and City Attributes (Road Centerline)*"""
        self._precheck(self.check_unique_id_frequency(config.feature_classes.road_centerline))
        self._precheck(self.check_unique_id_frequency(config.feature_classes.esz_boundary))
        self._precheck([error for error in self.check_attributes(config.feature_classes.road_centerline) if error.code in {"ERROR:GENERAL:MANDATORY_IS_NULL", "ERROR:GENERAL:MANDATORY_IS_BLANK"}])
        self._precheck([error for error in self.check_attributes(config.feature_classes.esz_boundary) if error.code in {"ERROR:GENERAL:MANDATORY_IS_NULL", "ERROR:GENERAL:MANDATORY_IS_BLANK"}])
        errors: list[ValidationErrorMessage] = self.check_road_esn()
        return errors

    @routine("Check Uniqueness (Road Centerline)", "Road Centerline", required_feature_classes={"road_centerline"})
    def check_uniqueness_road_centerline_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Uniqueness*"""
        fc_obj: NG911FeatureClass = config.feature_classes.road_centerline
        self._precheck(self.check_feature_class_exists(fc_obj))
        self._precheck(self.check_unique_id_frequency(fc_obj))
        errors: list[ValidationErrorMessage] = self.check_uniqueness(fc_obj)
        return errors

    # @routine("Check Next-Gen Against Legacy Fields (Road Centerline)", "Road Centerline", required_feature_classes={"road_centerline"})
    # def check_next_gen_against_legacy_fields_road_centerline_routine(self) -> list[ValidationErrorMessage]:
    #     """Validation routine *Check Next-Gen Against Legacy Fields (Road Centerline)*"""
    #     fc_obj: NG911FeatureClass[NG911RoadCenterlineFieldNamespace] = config.feature_classes.road_centerline
    #     self._precheck(self.check_feature_class_exists(fc_obj))
    #     errors: list[ValidationErrorMessage] = []
    #     raise NotImplementedError
    #     return errors

    @routine("Check Parities", "Road Centerline", required_feature_classes={"road_centerline"})
    def check_parities_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Parities*"""
        self._precheck(self.check_unique_id_frequency(config.feature_classes.road_centerline))
        errors: list[ValidationErrorMessage] = self.check_parities()
        return errors

    # @routine("Check for Cutbacks and Slivers", "Road Centerline", required_feature_classes={"road_centerline"})  # TODO: Fix
    # def check_for_cutbacks_routine(self) -> list[ValidationErrorMessage]:
    #     """Validation routine *Check for Cutbacks*"""
    #     self._precheck(self.check_unique_id_frequency(config.feature_classes.road_centerline))
    #     errors: list[ValidationErrorMessage] = self.check_road_geometry()
    #     return errors

    @routine("Check Address Range Directionality", "Road Centerline", required_feature_classes={"road_centerline"})
    def check_address_range_directionality_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Address Range Directionality*"""
        self._precheck(self.check_unique_id_frequency(config.feature_classes.road_centerline))
        self._precheck(self.check_attributes(config.feature_classes.road_centerline))
        errors: list[ValidationErrorMessage] = self.check_address_range_directionality()
        return errors

    @routine("Check Address Ranges", "Road Centerline", required_feature_classes={"road_centerline"})
    def check_address_ranges_routine(self) -> list[ValidationErrorMessage]:
        """Validation routine *Check Address Ranges*"""
        self._precheck(self.check_unique_id_frequency(config.feature_classes.road_centerline))
        self._precheck(self.check_parities())
        self._precheck(self.check_address_range_directionality(), False)
        errors: list[ValidationErrorMessage] = self.check_address_range_overlaps()
        return errors

    # @routine("Check Elevation Levels", "Road Centerline", required_feature_classes={"road_centerline"})  # TODO: Fix
    # def check_elevation_levels_routine(self) -> list[ValidationErrorMessage]:
    #     """Validation routine *Check Elevation Levels*"""
    #     errors: list[ValidationErrorMessage] = self.check_road_level()
    #     return errors

    # @routine("Check MSAGComm Consistency (Road Centerline)", "Road Centerline", required_feature_classes={"road_centerline"})
    # def check_msagcomm_consistency_road_centerline_routine(self) -> list[ValidationErrorMessage]:
    #     """Validation routine *Check MSAGComm Consistency (Road Centerline)*"""
    #     errors: list[ValidationErrorMessage] = self.check_road_msagcomm_consistency()
    #     return errors


if __name__ == "__main__":
    breakpoint()